From cfb0b9734a1b93d58814030825c813890d187593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 09:04:06 +0200 Subject: [PATCH 01/36] perf(runtime): bulk-tile padStart/padEnd fill instead of per-code-unit loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_pad_chunk cycled through the pad string one UTF-16 code unit at a time (a modulo, a surrogate-range check, and a push per unit), then finish_pad_result copied that chunk into a second buffer before the runtime string constructor copied it a third time. For the common case of an ASCII/BMP-non-surrogate pad string, none of the per-unit surrogate handling can ever trigger. pad_units_surrogate_free detects that case; build_pad_result_surrogate_free then encodes one pad cycle once and grows it to the needed length by doubling (tile_by_doubling), directly into the same buffer the receiver's bytes are copied into — replacing the per-unit loop and the extra assembly copy with a logarithmic number of bulk copies. The general per-unit build_pad_chunk path is unchanged and still used whenever the pad string itself contains a surrogate code unit. On the issue's own reproducer (padStart to a 1M-unit ASCII target), Perry/Node ratio drops from 79.57x to 11.45x, with the fitted slope moving from 0.948 toward Node's 0.490. Checksums match Node at every size tested (100 through 1,000,000), including the Unicode/astral-pad fallback path and every documented edge case (empty pad, negative/ fractional/Infinity target, mid-surrogate-pair truncation, a receiver that already carries a lone surrogate). repeat() is untouched. Fixes #10091 Claude-Session: https://claude.ai/code/session_01XU8SJ4eLa2vBHR9ukyvrdv (cherry picked from commit 2696bd69a83e899aa843346a7d828dc5da9c15cc) --- crates/perry-runtime/src/string/pad.rs | 217 ++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 8 deletions(-) diff --git a/crates/perry-runtime/src/string/pad.rs b/crates/perry-runtime/src/string/pad.rs index dce8cd5bee..6e7fddbab2 100644 --- a/crates/perry-runtime/src/string/pad.rs +++ b/crates/perry-runtime/src/string/pad.rs @@ -164,10 +164,30 @@ fn build_pad_chunk(pad_units: &[u16], pad_needed: usize) -> (Vec, bool) { (out, has_lone_surrogate) } +/// Wrap fully assembled result bytes (receiver + padding, in either order) in +/// the constructor matching the WTF-8/clean split, then canonicalize any +/// surrogate pair that now straddles the receiver/padding boundary. +/// `pad_has_lone_surrogate` reports the padding alone; the receiver's own +/// `STRING_FLAG_HAS_LONE_SURROGATES` is read here so every call site doesn't +/// have to. +fn wrap_pad_result( + s: *const StringHeader, + bytes: &[u8], + pad_has_lone_surrogate: bool, +) -> *mut StringHeader { + let receiver_has_lone_surrogate = unsafe { (*s).flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 }; + let result = if pad_has_lone_surrogate || receiver_has_lone_surrogate { + js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32) + } else { + js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + }; + super::concat::canonicalize_surrogate_pairs(result) +} + /// Assemble the padded result from the receiver's raw bytes and a padding -/// chunk, using the WTF-8-flagged constructor when either side may hold a -/// lone surrogate (the receiver's existing flag, or one newly introduced by -/// truncating the pad string mid-surrogate-pair). +/// chunk built by the general per-code-unit `build_pad_chunk` path (used only +/// when the pad string itself contains a surrogate code unit — see +/// `pad_units_surrogate_free`). fn finish_pad_result( s: *const StringHeader, str_data: &str, @@ -175,7 +195,6 @@ fn finish_pad_result( pad_has_lone_surrogate: bool, prepend_pad: bool, ) -> *mut StringHeader { - let receiver_has_lone_surrogate = unsafe { (*s).flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 }; let mut bytes = Vec::with_capacity(str_data.len() + pad_chunk.len()); if prepend_pad { bytes.extend_from_slice(pad_chunk); @@ -184,12 +203,89 @@ fn finish_pad_result( bytes.extend_from_slice(str_data.as_bytes()); bytes.extend_from_slice(pad_chunk); } - let result = if pad_has_lone_surrogate || receiver_has_lone_surrogate { - js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32) + wrap_pad_result(s, &bytes, pad_has_lone_surrogate) +} + +/// True when no code unit in a decoded pad string is a UTF-16 surrogate +/// (high or low). Such a pad string can never straddle a surrogate pair +/// across a tile-cycle boundary and truncating it can never produce a lone +/// surrogate, so every per-unit decision `build_pad_chunk` makes (the +/// lookahead, the two moduli, the lone-surrogate bookkeeping) is dead weight +/// — this is also the overwhelmingly common case (issue #10091: a plain +/// ASCII pad string, including the default single space). +fn pad_units_surrogate_free(pad_units: &[u16]) -> bool { + !pad_units.iter().any(|&u| (0xD800..=0xDFFF).contains(&u)) +} + +/// Grow `bytes[start..start + total_len]` from an already-written +/// `unit_len`-byte prefix by repeatedly doubling the written region. Source +/// and destination ranges never overlap: each step copies at most as many +/// bytes as are already written, so the copied range always ends at or +/// before where it's copied to. Returns the number of bulk copies performed +/// — O(log(total_len / unit_len)), never one per output unit (issue #10091). +fn tile_by_doubling(bytes: &mut [u8], start: usize, unit_len: usize, total_len: usize) -> u32 { + let mut written = unit_len; + let mut steps = 0u32; + while written < total_len { + let chunk = written.min(total_len - written); + bytes.copy_within(start..start + chunk, start + written); + written += chunk; + steps += 1; + } + steps +} + +/// Build the fully assembled padded result (receiver + padding, in either +/// order) for a surrogate-free pad string, without a per-code-unit loop. +/// Encodes exactly one cycle of `pad_units` once, then bulk-tiles it to the +/// needed length by doubling (`tile_by_doubling`) and appends the leftover +/// partial-cycle remainder — a logarithmic number of bulk copies rather than +/// one modulo-and-push per output code unit. +fn build_pad_result_surrogate_free( + str_data: &[u8], + pad_units: &[u16], + pad_needed: usize, + prepend_pad: bool, +) -> Vec { + let unit_count = pad_units.len(); + // One full cycle, plus each unit's cumulative byte offset so the + // sub-one-cycle remainder (always the *first* `remainder_units` units — + // cycling restarts at index 0 every time) can be sliced out below without + // re-encoding it. + let mut cycle = Vec::with_capacity(unit_count * 3); + let mut offsets = Vec::with_capacity(unit_count + 1); + offsets.push(0usize); + for &unit in pad_units { + // No unit here is a surrogate, so this never sets the lone-surrogate + // flag — that's exactly what makes this path safe to skip. + super::char_ops::push_code_unit_wtf8(&mut cycle, unit); + offsets.push(cycle.len()); + } + let cycle_len = cycle.len(); + + let full_cycles = pad_needed / unit_count; + let remainder_units = pad_needed % unit_count; + let remainder_len = offsets[remainder_units]; + let full_bytes = cycle_len * full_cycles; + let pad_len = full_bytes + remainder_len; + + let mut bytes = vec![0u8; str_data.len() + pad_len]; + let (pad_start, str_start) = if prepend_pad { + (0, pad_len) } else { - js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + (str_data.len(), 0) }; - super::concat::canonicalize_surrogate_pairs(result) + bytes[str_start..str_start + str_data.len()].copy_from_slice(str_data); + + if full_bytes > 0 { + bytes[pad_start..pad_start + cycle_len].copy_from_slice(&cycle); + tile_by_doubling(&mut bytes, pad_start, cycle_len, full_bytes); + } + if remainder_len > 0 { + bytes[pad_start + full_bytes..pad_start + full_bytes + remainder_len] + .copy_from_slice(&cycle[..remainder_len]); + } + bytes } /// Pad the start of a string to reach target length (in UTF-16 code units). @@ -231,6 +327,11 @@ pub extern "C" fn js_string_pad_start( let pad_needed = target_len - current_len; let pad_units = decode_wtf8_units(pad_bytes); + if pad_units_surrogate_free(&pad_units) { + let bytes = + build_pad_result_surrogate_free(str_data.as_bytes(), &pad_units, pad_needed, true); + return wrap_pad_result(s, &bytes, false); + } let (pad_chunk, pad_has_lone_surrogate) = build_pad_chunk(&pad_units, pad_needed); finish_pad_result(s, str_data, &pad_chunk, pad_has_lone_surrogate, true) } @@ -274,6 +375,11 @@ pub extern "C" fn js_string_pad_end( let pad_needed = target_len - current_len; let pad_units = decode_wtf8_units(pad_bytes); + if pad_units_surrogate_free(&pad_units) { + let bytes = + build_pad_result_surrogate_free(str_data.as_bytes(), &pad_units, pad_needed, false); + return wrap_pad_result(s, &bytes, false); + } let (pad_chunk, pad_has_lone_surrogate) = build_pad_chunk(&pad_units, pad_needed); finish_pad_result(s, str_data, &pad_chunk, pad_has_lone_surrogate, false) } @@ -531,3 +637,98 @@ mod builder_tests { } } } + +/// Issue #10091: the surrogate-free fast path must (a) route ASCII/BMP pad +/// strings there at all, (b) produce byte-identical output to the general +/// per-code-unit `build_pad_chunk` path it replaces, across cycle-boundary +/// remainders and multi-byte-but-surrogate-free pad characters, and (c) +/// actually perform a logarithmic, not linear, number of bulk copies. +#[cfg(test)] +mod surrogate_free_fast_path_tests { + use super::*; + + #[test] + fn ascii_pad_string_is_surrogate_free() { + assert!(pad_units_surrogate_free(&decode_wtf8_units(b"aBcD"))); + assert!(pad_units_surrogate_free(&decode_wtf8_units(b" "))); + } + + #[test] + fn astral_pad_string_is_not_surrogate_free() { + // "😀" decodes to a high/low surrogate pair. + assert!(!pad_units_surrogate_free(&decode_wtf8_units( + "😀".as_bytes() + ))); + } + + /// Cross-check the fast path against the general per-unit path it + /// bypasses, across pad_needed values that land exactly on, one below, + /// and one above a full-cycle boundary, plus pad strings whose units + /// encode to 1, 2 and 3 WTF-8 bytes (still surrogate-free throughout). + #[test] + fn matches_general_path_across_cycle_boundaries_and_encodings() { + for pad_str in ["a", "aBcD", "é", "€ab", " "] { + let pad_units = decode_wtf8_units(pad_str.as_bytes()); + assert!(pad_units_surrogate_free(&pad_units)); + let cycle_len = pad_units.len(); + for pad_needed in 1..=(cycle_len * 3 + 2) { + let (expected, expected_lone) = build_pad_chunk(&pad_units, pad_needed); + assert!(!expected_lone, "surrogate-free pad must never set the flag"); + + let start_bytes = + build_pad_result_surrogate_free(b"RECEIVER", &pad_units, pad_needed, true); + assert_eq!( + &start_bytes[..expected.len()], + expected.as_slice(), + "padStart mismatch for pad={pad_str:?} pad_needed={pad_needed}" + ); + assert_eq!(&start_bytes[expected.len()..], b"RECEIVER"); + + let end_bytes = + build_pad_result_surrogate_free(b"RECEIVER", &pad_units, pad_needed, false); + assert_eq!(&end_bytes[..b"RECEIVER".len()], b"RECEIVER"); + assert_eq!(&end_bytes[b"RECEIVER".len()..], expected.as_slice()); + } + } + } + + #[test] + fn empty_receiver_and_single_unit_pad_needed() { + let pad_units = decode_wtf8_units(b"x"); + let bytes = build_pad_result_surrogate_free(b"", &pad_units, 1, true); + assert_eq!(bytes, b"x"); + } + + /// The whole point of issue #10091: padding to a million units must not + /// take a million per-unit steps. log2(1_000_000) ~= 20; 32 leaves ample + /// margin while still being nowhere near linear. + #[test] + fn tile_by_doubling_is_logarithmic_not_linear() { + let mut buf = vec![0u8; 1_000_001]; + buf[0] = b'x'; + let total_len = buf.len(); + let steps = tile_by_doubling(&mut buf, 0, 1, total_len); + assert!( + steps <= 32, + "expected O(log n) bulk copies for 1M units, got {steps}" + ); + assert!(buf.iter().all(|&b| b == b'x')); + } + + #[test] + fn pad_start_and_pad_end_use_fast_path_for_large_ascii_target() { + let source = js_string_from_str("!"); + let pad = js_string_from_str("aBcD"); + let target = 1_000_001.0; + + let start = js_string_pad_start(source, target, pad); + unsafe { assert_eq!((*start).utf16_len, 1_000_001) }; + assert!(string_as_str(start).starts_with("aBcD")); + assert!(string_as_str(start).ends_with('!')); + + let end = js_string_pad_end(source, target, pad); + unsafe { assert_eq!((*end).utf16_len, 1_000_001) }; + assert!(string_as_str(end).starts_with('!')); + assert!(string_as_str(end).ends_with("aBcD")); + } +} From 679286a9c5cd6800cb0c77fbbd11414695dfd58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 09:05:06 +0200 Subject: [PATCH 02/36] chore: key padStart/padEnd changeset to PR 10115 Claude-Session: https://claude.ai/code/session_01XU8SJ4eLa2vBHR9ukyvrdv (cherry picked from commit b720c4f60eef4050a9f53ec51a3be7685666dbef) --- changelog.d/10115-padstart-bulk-tile.md | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 changelog.d/10115-padstart-bulk-tile.md diff --git a/changelog.d/10115-padstart-bulk-tile.md b/changelog.d/10115-padstart-bulk-tile.md new file mode 100644 index 0000000000..b4f0b13fcb --- /dev/null +++ b/changelog.d/10115-padstart-bulk-tile.md @@ -0,0 +1,30 @@ +**`padStart`/`padEnd` no longer fill their padding one UTF-16 code unit at +a time for an ASCII/surrogate-free pad string** (#10091), which was +79.57x Node at a 1M-unit target (24.51x at 10k, 52.61x at 100k) and grew +worse with size — `build_pad_chunk`'s per-unit loop did a modulo, a +surrogate-range check, and a byte push for every output unit, work that +can never actually branch differently when no unit in the pad string is +a surrogate. + +`pad_units_surrogate_free` detects that (overwhelmingly common) case; +`build_pad_result_surrogate_free` then encodes one pad cycle once and +grows it to the needed length by doubling (`tile_by_doubling`) directly +into the same buffer the receiver's bytes are copied into — a +logarithmic number of bulk copies instead of one decision per output +unit, and one fewer full-buffer copy than before (the tiled result no +longer passes through a separate `pad_chunk` buffer on its way into the +assembled string). The general per-code-unit path — needed only when the +pad string itself contains a surrogate, since cycling it can straddle a +surrogate pair across a cycle boundary or truncate one into a lone +surrogate — is unchanged. + +On the issue's own reproducer (`'!'.padStart(n*4+1, "aBcD")`), the +Perry/Node ratio at n=1,000,000 drops from 79.57x to 11.45x (10k: +24.51x → 3.88x; 100k: 52.61x → 8.75x), with the fitted log-log slope +moving from 0.948 toward Node's 0.490. Checksums matched Node at every +size, including the unchanged Unicode/astral-pad fallback path and every +spec edge case: empty pad string, an already-long-enough receiver, +fractional/negative/`Infinity` target lengths, an astral pad string +truncated mid-surrogate-pair, and a receiver that already carries a lone +surrogate being re-padded. `repeat` was already doubling-copy based and +is untouched. From 95f7fee3b9f7f0ebca2ade0d8a939dfca6e04a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 13:52:38 +0200 Subject: [PATCH 03/36] fix(runtime): honor number-to-string ties-to-even (cherry picked from commit 246ddf7d00f1885e0edb055caa724f5347913303) --- .../10125-number-to-string-tie-even.md | 4 ++ crates/perry-runtime/src/string/format.rs | 45 +++----------- crates/perry-runtime/src/string/tests.rs | 61 +++++++++++++++++++ .../test_gap_10093_number_to_string_tie.ts | 57 +++++++++++++++++ 4 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 changelog.d/10125-number-to-string-tie-even.md create mode 100644 test-files/test_gap_10093_number_to_string_tie.ts diff --git a/changelog.d/10125-number-to-string-tie-even.md b/changelog.d/10125-number-to-string-tie-even.md new file mode 100644 index 0000000000..3920ee9d03 --- /dev/null +++ b/changelog.d/10125-number-to-string-tie-even.md @@ -0,0 +1,4 @@ +Fix decimal number-to-string conversion to use ECMAScript's round-half-to-even +tie rule. `String`, template literals, `Number.prototype.toString` and +`toPrecision`, array joins, implicit concatenation, and JSON serialization now +agree on the shortest representation for exact-tie doubles. diff --git a/crates/perry-runtime/src/string/format.rs b/crates/perry-runtime/src/string/format.rs index 0d106aaab9..17e41b0bb0 100644 --- a/crates/perry-runtime/src/string/format.rs +++ b/crates/perry-runtime/src/string/format.rs @@ -175,17 +175,13 @@ pub(crate) fn js_format_f64(value: f64) -> String { // Integer-like, format without decimal format!("{}", value as i64) } else { - // ECMAScript NumberToString: switch to scientific notation when - // |n| >= 10^21 or |n| < 10^-6 (otherwise Rust's `{}` produces - // 300-digit decimals for `Number.MAX_VALUE` and 16-digit - // 0.000…0002… decimals for `Number.EPSILON`, neither of which - // matches Node's output). - let abs = value.abs(); - if !(1e-6..1e21).contains(&abs) { - fix_exponent_format(&format!("{:e}", value)) - } else { - format!("{}", value) - } + // Rust's Display formatter also emits a shortest round-tripping + // decimal, but it can choose the odd final digit when the two shortest + // candidates are equidistant. ECMA-262 Number::toString requires the + // even candidate. `ryu-js` implements that tie-break together with + // JavaScript's fixed/scientific notation thresholds. + let mut buffer = ryu_js::Buffer::new(); + buffer.format_finite(value).to_owned() } } @@ -716,30 +712,7 @@ pub(crate) fn fix_exponent_format(s: &str) -> String { } } -/// Format a number per JS toString rules (helper for toPrecision when precision=0) +/// Format a number per JS toString rules (helper for toPrecision with no precision). fn format_number_for_js(value: f64) -> String { - if value.is_nan() { - return "NaN".to_string(); - } - if value.is_infinite() { - return if value > 0.0 { - "Infinity".to_string() - } else { - "-Infinity".to_string() - }; - } - if value == 0.0 { - return "0".to_string(); - } - if value.fract() == 0.0 && value.abs() < 1e15 { - format!("{}", value as i64) - } else { - // ECMAScript NumberToString — see js_number_to_string for rationale. - let abs = value.abs(); - if !(1e-6..1e21).contains(&abs) { - fix_exponent_format(&format!("{:e}", value)) - } else { - format!("{}", value) - } - } + js_format_f64(value) } diff --git a/crates/perry-runtime/src/string/tests.rs b/crates/perry-runtime/src/string/tests.rs index 6b62dcb235..0d8b79c2ef 100644 --- a/crates/perry-runtime/src/string/tests.rs +++ b/crates/perry-runtime/src/string/tests.rs @@ -22,6 +22,67 @@ fn fnv1a_for_test(bytes: &[u8]) -> u64 { h } +#[test] +fn number_to_string_uses_ecmascript_tie_to_even() { + let cases = [ + // The issue's exact tie, its adjacent doubles, and positive counterpart. + (0xc0d9_2b80_21ff_ffff, "-25774.00207519531"), + (0xc0d9_2b80_2200_0000, "-25774.002075195312"), + (0xc0d9_2b80_2200_0001, "-25774.002075195316"), + (0x40d9_2b80_2200_0000, "25774.002075195312"), + // Further exact ties found by the issue's seeded differential sweep. + (0xc03c_d941_0000_0000, "-28.848648071289062"), + (0x40d1_4b6e_da00_0000, "17709.732055664062"), + (0x40c1_b0ff_d400_0000, "9057.998657226562"), + ]; + for (bits, expected) in cases { + assert_eq!(js_format_f64(f64::from_bits(bits)), expected, "{bits:016x}"); + } +} + +#[test] +fn number_to_string_preserves_special_values_and_notation_boundaries() { + let cases = [ + (1e21, "1e+21"), + (1e-7, "1e-7"), + (1e20, "100000000000000000000"), + (1e-6, "0.000001"), + (f64::MAX, "1.7976931348623157e+308"), + (f64::MIN_POSITIVE, "2.2250738585072014e-308"), + (f64::from_bits(1), "5e-324"), + (f64::EPSILON, "2.220446049250313e-16"), + (999_999_999_999_999.0, "999999999999999"), + (999_999_999_999_999.9, "999999999999999.9"), + (1_000_000_000_000_000.0, "1000000000000000"), + (1_000_000_000_000_000.1, "1000000000000000.1"), + (-0.0, "0"), + (f64::INFINITY, "Infinity"), + (f64::NEG_INFINITY, "-Infinity"), + ]; + for (value, expected) in cases { + assert_eq!(js_format_f64(value), expected, "{:016x}", value.to_bits()); + } + assert_eq!(js_format_f64(f64::NAN), "NaN"); +} + +#[test] +fn number_to_string_million_seeded_doubles_match_the_ecmascript_formatter() { + let mut seed = 0x1234_5678_u32; + let mut oracle = ryu_js::Buffer::new(); + for index in 0..1_000_000 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let value = (seed as f64 / 4_294_967_296.0 - 0.5) * 1_000_000.0 / 7.0; + assert_eq!( + js_format_f64(value), + oracle.format_finite(value), + "seeded value {index}, bits {:016x}", + value.to_bits() + ); + } +} + #[test] fn test_string_create() { let data = b"hello"; diff --git a/test-files/test_gap_10093_number_to_string_tie.ts b/test-files/test_gap_10093_number_to_string_tie.ts new file mode 100644 index 0000000000..350ad9035f --- /dev/null +++ b/test-files/test_gap_10093_number_to_string_tie.ts @@ -0,0 +1,57 @@ +function fromBits(hi: number, lo: number): number { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setUint32(0, hi, false); + view.setUint32(4, lo, false); + return view.getFloat64(0, false); +} + +function observableForms(value: number): string { + return [ + String(value), + `${value}`, + value.toString(), + value.toPrecision(), + JSON.stringify(value), + [value].join(","), + "" + value, + value + "", + ].join("|"); +} + +const ties = [ + // The issue's exact tie, its adjacent doubles, and positive counterpart. + fromBits(0xc0d92b80, 0x21ffffff), + fromBits(0xc0d92b80, 0x22000000), + fromBits(0xc0d92b80, 0x22000001), + fromBits(0x40d92b80, 0x22000000), + // Further exact ties found by the issue's seeded differential sweep. + fromBits(0xc03cd941, 0x00000000), + fromBits(0x40d14b6e, 0xda000000), + fromBits(0x40c1b0ff, 0xd4000000), +]; +for (let i = 0; i < ties.length; i++) { + console.log("tie", i, observableForms(ties[i])); +} + +const boundaries: [string, number][] = [ + ["large-scientific", 1e21], + ["small-scientific", 1e-7], + ["large-fixed", 1e20], + ["small-fixed", 1e-6], + ["max-value", Number.MAX_VALUE], + ["min-normal", 2.2250738585072014e-308], + ["min-value", Number.MIN_VALUE], + ["epsilon", Number.EPSILON], + ["below-fast-integer", 999999999999999], + ["below-fast-fraction", 999999999999999.9], + ["above-fast-integer", 1000000000000000], + ["above-fast-fraction", 1000000000000000.1], + ["negative-zero", -0], + ["positive-infinity", Infinity], + ["negative-infinity", -Infinity], + ["nan", NaN], +]; +for (let i = 0; i < boundaries.length; i++) { + console.log(boundaries[i][0], observableForms(boundaries[i][1])); +} From b2330d234cbd0e65e7c9eb5161586a69e69043f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 14:10:17 +0200 Subject: [PATCH 04/36] perf(runtime): cache call/apply rest dispatch (cherry picked from commit 0e7690081e587f516f7ae58f13441300a4a671e1) --- changelog.d/0000-call-apply-dispatch-cache.md | 7 + crates/perry-runtime/src/closure/registry.rs | 120 +++++++++++++++- .../fixtures/issue_10085_dispatch/imported.ts | 5 + .../test_gap_10085_call_apply_dispatch.ts | 131 ++++++++++++++++++ 4 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 changelog.d/0000-call-apply-dispatch-cache.md create mode 100644 test-files/fixtures/issue_10085_dispatch/imported.ts create mode 100644 test-files/test_gap_10085_call_apply_dispatch.ts diff --git a/changelog.d/0000-call-apply-dispatch-cache.md b/changelog.d/0000-call-apply-dispatch-cache.md new file mode 100644 index 0000000000..543e5f01b0 --- /dev/null +++ b/changelog.d/0000-call-apply-dispatch-cache.md @@ -0,0 +1,7 @@ +### Performance + +- **`Function.prototype.call`, `Function.prototype.apply`, and direct spread + calls no longer hash-probe the closure-body registry on every invocation.** + Their rest-parameter check now shares the existing four-entry dispatch memo, + retaining late-registration invalidation and all 0–16 argument, receiver, + bound-function, rest-array, and imported-class semantics. Refs #10085. diff --git a/crates/perry-runtime/src/closure/registry.rs b/crates/perry-runtime/src/closure/registry.rs index f77ba5dcc3..48a114cefe 100644 --- a/crates/perry-runtime/src/closure/registry.rs +++ b/crates/perry-runtime/src/closure/registry.rs @@ -219,6 +219,8 @@ crate::perry_thread_local! { /// The record for `func_ptr`, if module init registered anything about it. #[inline(always)] fn body_record(func_ptr: *const u8) -> Option { + #[cfg(test)] + BODY_RECORD_LOOKUPS.with(|lookups| lookups.set(lookups.get() + 1)); CLOSURE_BODY_REGISTRY.with(|r| r.borrow().get(&(func_ptr as usize)).copied()) } @@ -307,6 +309,7 @@ crate::perry_thread_local! { #[cfg(test)] std::thread_local! { static RESOLVE_STRATEGY_SLOW_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static BODY_RECORD_LOOKUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; } #[derive(Clone, Copy)] @@ -437,6 +440,27 @@ mod dispatch_recent_tests { 4.0 } + extern "C" fn add_two(_: *const ClosureHeader, left: f64, right: f64) -> f64 { + left + right + } + + extern "C" fn identify_rest_array(_: *const ClosureHeader, value: f64) -> f64 { + let is_pointer = value.to_bits() >> 48 == crate::value::POINTER_TAG >> 48; + if is_pointer { + 1.0 + } else { + 0.0 + } + } + + fn stack_closure(func_ptr: *const u8) -> ClosureHeader { + ClosureHeader { + func_ptr, + capture_count: 0, + type_tag: CLOSURE_MAGIC, + } + } + #[test] fn four_alternating_bodies_stay_out_of_the_hash_lookup() { let bodies = [ @@ -486,6 +510,90 @@ mod dispatch_recent_tests { "late registration must evict a body from every recent-cache slot" ); } + + #[test] + fn repeated_array_calls_probe_the_body_registry_only_once() { + let body = add_two as *const u8; + let closure = stack_closure(body); + let args = [20.0, 22.0]; + invalidate_dispatch_strategy(body); + RESOLVE_STRATEGY_SLOW_CALLS.with(|calls| calls.set(0)); + BODY_RECORD_LOOKUPS.with(|lookups| lookups.set(0)); + + for _ in 0..32 { + assert_eq!( + unsafe { + crate::closure::js_closure_call_array( + &closure as *const ClosureHeader as i64, + args.as_ptr(), + args.len() as i64, + ) + }, + 42.0 + ); + } + + assert_eq!( + RESOLVE_STRATEGY_SLOW_CALLS.with(|calls| calls.get()), + 1, + "a warm non-rest call-array path must not hash-probe the registry per call" + ); + assert_eq!( + BODY_RECORD_LOOKUPS.with(|lookups| lookups.get()), + 1, + "32 repeated calls must perform one total closure-body hash lookup" + ); + } + + #[test] + fn late_rest_registration_invalidates_the_call_array_memo() { + let body = identify_rest_array as *const u8; + let closure = stack_closure(body); + let direct_arg = [0.0]; + invalidate_dispatch_strategy(body); + RESOLVE_STRATEGY_SLOW_CALLS.with(|calls| calls.set(0)); + BODY_RECORD_LOOKUPS.with(|lookups| lookups.set(0)); + + assert_eq!( + unsafe { + crate::closure::js_closure_call_array( + &closure as *const ClosureHeader as i64, + direct_arg.as_ptr(), + direct_arg.len() as i64, + ) + }, + 0.0, + "the first unregistered call must use direct dispatch" + ); + assert_eq!(RESOLVE_STRATEGY_SLOW_CALLS.with(|calls| calls.get()), 1); + assert_eq!(BODY_RECORD_LOOKUPS.with(|lookups| lookups.get()), 1); + + js_register_closure_rest(body, 0); + let rest_args = [1.0, 2.0, 3.0]; + for _ in 0..2 { + assert_eq!( + unsafe { + crate::closure::js_closure_call_array( + &closure as *const ClosureHeader as i64, + rest_args.as_ptr(), + rest_args.len() as i64, + ) + }, + 1.0, + "late registration must switch the cached body to rest dispatch" + ); + } + assert_eq!( + RESOLVE_STRATEGY_SLOW_CALLS.with(|calls| calls.get()), + 2, + "registration should cause one fresh registry probe, then remain cached" + ); + assert_eq!( + BODY_RECORD_LOOKUPS.with(|lookups| lookups.get()), + 2, + "the initial call and post-registration miss should be the only hash lookups" + ); + } } #[no_mangle] @@ -557,7 +665,17 @@ pub fn lookup_closure_rest(func_ptr: *const u8) -> Option { #[inline(always)] pub fn lookup_closure_rest_full(func_ptr: *const u8) -> Option<(u32, RestDispatchKind)> { - body_record(func_ptr)?.rest() + // Rest-ness is part of the same immutable-at-dispatch-time answer as + // bound routing and declared arity. In particular, do not bypass + // `DISPATCH_RECENT` here: `js_closure_call_array` probes this on every + // Function.prototype.call/apply invocation, and a direct `body_record` + // read would pay a TLS RefCell borrow plus a pointer hash lookup every + // time through that hot path. Registration invalidates the memo, so the + // #6475 call-before-registration case still observes a later rest entry. + match resolve_strategy(func_ptr).kind() { + DispatchKind::Rest(fixed_arity, kind) => Some((fixed_arity, kind)), + _ => None, + } } /// Register a closure body's declared param count (for closures WITHOUT a rest diff --git a/test-files/fixtures/issue_10085_dispatch/imported.ts b/test-files/fixtures/issue_10085_dispatch/imported.ts new file mode 100644 index 0000000000..a3acba650f --- /dev/null +++ b/test-files/fixtures/issue_10085_dispatch/imported.ts @@ -0,0 +1,5 @@ +export class ImportedClass { + static describe(value: number): string { + return `imported:${value}`; + } +} diff --git a/test-files/test_gap_10085_call_apply_dispatch.ts b/test-files/test_gap_10085_call_apply_dispatch.ts new file mode 100644 index 0000000000..44d8e025e9 --- /dev/null +++ b/test-files/test_gap_10085_call_apply_dispatch.ts @@ -0,0 +1,131 @@ +// #10085: Function.prototype.call/apply and direct spread calls all reach +// js_closure_call_array. Keep its cached non-rest path and its rest-bundling +// detour semantically identical across every supported dispatch arity. + +import { ImportedClass } from "./fixtures/issue_10085_dispatch/imported.ts"; + +function check(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +function restShape(first: number, ...rest: number[]): string { + return `${first}|${rest.length}|${rest.length === 0 ? -1 : rest[rest.length - 1]}`; +} + +const restFunctions: any[] = []; +restFunctions.push(restShape); +const restValue: any = restFunctions[0]; + +// Rest closures must still bundle through .call, .apply, and direct spread at +// arities 1..=16. This pins #653's high-arity fix while the metadata probe is +// switched from a registry lookup to the dispatch-strategy memo. +for (let n = 1; n <= 16; n++) { + const args: number[] = []; + for (let i = 1; i <= n; i++) args.push(i); + const expected = `1|${n - 1}|${n === 1 ? -1 : n}`; + const direct = restValue(...args); + const viaCall = restValue.call({ ignored: true }, ...args); + const viaApply = restValue.apply({ ignored: true }, args); + check(direct === expected, `rest direct arity ${n}: ${direct}`); + check(viaCall === expected, `rest call arity ${n}: ${viaCall}`); + check(viaApply === expected, `rest apply arity ${n}: ${viaApply}`); +} + +// A 16-parameter non-rest closure called with 0..=16 values exercises every +// js_closure_callN arm. Missing values must still be padded with undefined. +function arity16( + a0: any, + a1: any, + a2: any, + a3: any, + a4: any, + a5: any, + a6: any, + a7: any, + a8: any, + a9: any, + a10: any, + a11: any, + a12: any, + a13: any, + a14: any, + a15: any, +): number { + const values = [ + a0, + a1, + a2, + a3, + a4, + a5, + a6, + a7, + a8, + a9, + a10, + a11, + a12, + a13, + a14, + a15, + ]; + let count = 0; + for (const value of values) if (value !== undefined) count++; + return count; +} + +for (let n = 0; n <= 16; n++) { + const args: number[] = []; + for (let i = 0; i < n; i++) args.push(i + 1); + const direct = (arity16 as any)(...args); + const viaCall = (arity16 as any).call(null, ...args); + const viaApply = (arity16 as any).apply(null, args); + check(direct === n, `arity16 direct ${n}: ${direct}`); + check(viaCall === n, `arity16 call ${n}: ${viaCall}`); + check(viaApply === n, `arity16 apply ${n}: ${viaApply}`); +} + +function ordinary(this: { bias: number }, left: number, right: number): number { + return this.bias + left + right; +} + +const receiver = { + bias: 40, + ordinary, + makeArrow() { + return (value: number) => this.bias + value; + }, +}; +check(receiver.ordinary(1, 1) === 42, "ordinary direct receiver"); +check(receiver.ordinary.call(receiver, 1, 1) === 42, "ordinary call receiver"); +check(receiver.ordinary.apply(receiver, [1, 1]) === 42, "ordinary apply receiver"); + +const arrow = receiver.makeArrow(); +check(arrow(2) === 42, "arrow direct receiver"); +check(arrow.call({ bias: 100 }, 2) === 42, "arrow call keeps lexical receiver"); +check(arrow.apply({ bias: 100 }, [2]) === 42, "arrow apply keeps lexical receiver"); + +const bound = ordinary.bind(receiver, 1); +check(bound(1) === 42, "bound direct receiver"); +check(bound.call({ bias: 100 }, 1) === 42, "bound call keeps receiver"); +check(bound.apply({ bias: 100 }, [1]) === 42, "bound apply keeps receiver"); + +function invokeImportedClass(value: any, input: number): string { + return `${typeof value}:${value.describe(input)}`; +} + +// Imported class refs share the 0x7FFE tag with bridged int32 values. They +// must remain callable objects while call-array unboxes genuine integers. +const classArgs: any[] = [ImportedClass, 7]; +const classExpected = "function:imported:7"; +check((invokeImportedClass as any)(...classArgs) === classExpected, "class direct spread"); +check( + (invokeImportedClass as any).call(null, ...classArgs) === classExpected, + "class call spread", +); +check( + (invokeImportedClass as any).apply(null, classArgs) === classExpected, + "class apply", +); + +console.log("ok"); From 1c1f1a25688087c5d8a458fa26924fa592d66636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 14:13:53 +0200 Subject: [PATCH 05/36] chore: number changelog fragment for PR 10127 (cherry picked from commit 0d6eb03cff25232e533b3d5c8a741f9727605b12) --- ...apply-dispatch-cache.md => 10127-call-apply-dispatch-cache.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-call-apply-dispatch-cache.md => 10127-call-apply-dispatch-cache.md} (100%) diff --git a/changelog.d/0000-call-apply-dispatch-cache.md b/changelog.d/10127-call-apply-dispatch-cache.md similarity index 100% rename from changelog.d/0000-call-apply-dispatch-cache.md rename to changelog.d/10127-call-apply-dispatch-cache.md From ae116bb439fa5cfb3feaba4b4511e91a713a8430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:46:51 +0200 Subject: [PATCH 06/36] fix(worker_threads): clone Uint8Array payloads across workers (cherry picked from commit 7cd0435637b35311b6678566ad24af5855abee7b) --- changelog.d/10103-worker-uint8array.md | 3 + crates/perry-runtime/src/thread.rs | 44 ++++++- .../src/thread_transfer_guard_tests.rs | 33 ++++++ ...ue_worker_threads_cross_thread_delivery.rs | 108 ++++++++++++++++++ 4 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 changelog.d/10103-worker-uint8array.md diff --git a/changelog.d/10103-worker-uint8array.md b/changelog.d/10103-worker-uint8array.md new file mode 100644 index 0000000000..9a8d7dc649 --- /dev/null +++ b/changelog.d/10103-worker-uint8array.md @@ -0,0 +1,3 @@ +### Fixed + +- Preserve `Uint8Array` bytes and identity across native Worker structured-clone boundaries, including a Web-style parent Worker talking to a `node:worker_threads` `parentPort` child (#10103). diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 6d18eb1c03..504a3844d7 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -266,6 +266,12 @@ pub enum SerializedValue { /// another thread's arena. Date(f64), + /// A constructor-created `Uint8Array`, copied byte-for-byte into fresh + /// buffer storage on the receiving thread. Perry represents these with a + /// `BufferHeader` plus an address-keyed brand, so the brand must be + /// restored as well as the bytes (#10103). + Uint8Array(Vec), + /// An `fs.promises.FileHandle` crossing a `perry/thread` boundary. /// Perry's fd registry is thread-local, so handles are not transferable; /// deserialize as a FileHandle-shaped object with `fd === -1`. @@ -282,8 +288,8 @@ pub enum SerializedValue { SharedArrayBuffer { addr: usize }, /// A value whose runtime type cannot cross a `perry/thread` boundary - /// (Map, Set, Promise, Error, TypedArray, Buffer, Symbol, Temporal, - /// native handles, unmaterialized lazy JSON arrays, …). + /// (Map, Set, Promise, Error, non-Uint8 TypedArray, Buffer, Symbol, + /// Temporal, native handles, unmaterialized lazy JSON arrays, …). /// /// The serializer used to lower every one of these to `Inline(TAG_UNDEFINED)`, /// so a capture/return of such a value crossed silently as `undefined` @@ -410,6 +416,23 @@ pub unsafe fn serialize_nanbox_for_thread(bits: u64) -> SerializedValue { }; } + // Uint8Array can be backed by an ordinary GC allocation, a registered + // view, or an external BufferHeader with no preceding GcHeader. Brand + // detection must therefore precede the GcHeader read below, just like + // SharedArrayBuffer detection does. Always read through buffer_data so + // views and external storage copy their authoritative byte window. + if crate::buffer::is_uint8array_buffer(raw_ptr as usize) { + let buffer = raw_ptr as *const crate::buffer::BufferHeader; + let len = (*buffer).length as usize; + let data = crate::buffer::buffer_data(buffer); + let bytes = if len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(data, len).to_vec() + }; + return SerializedValue::Uint8Array(bytes); + } + // Check GcHeader to determine type let header = raw_ptr.sub(gc::GC_HEADER_SIZE) as *const gc::GcHeader; let obj_type = (*header).obj_type; @@ -894,6 +917,23 @@ pub unsafe fn deserialize_nanbox_on_current_thread(sv: &SerializedValue) -> u64 crate::date::alloc_date_cell(*ts).to_bits() } + SerializedValue::Uint8Array(bytes) => { + // Rebuild both halves of Perry's Uint8Array representation: fresh + // BufferHeader storage and the constructor-brand side-table entry. + let len = u32::try_from(bytes.len()).expect("serialized Uint8Array exceeds u32::MAX"); + let buffer = crate::buffer::buffer_alloc(len); + (*buffer).length = len; + if !bytes.is_empty() { + ptr::copy_nonoverlapping( + bytes.as_ptr(), + crate::buffer::buffer_data_mut(buffer), + bytes.len(), + ); + } + crate::buffer::mark_as_uint8array(buffer as usize); + JSValue::pointer(buffer as *const u8).bits() + } + SerializedValue::DetachedFileHandle => match fs_thread_codec() { Some(codec) => (codec.build_detached)().to_bits(), // Unreachable in practice: the variant is only produced by an diff --git a/crates/perry-runtime/src/thread_transfer_guard_tests.rs b/crates/perry-runtime/src/thread_transfer_guard_tests.rs index bc8a1c0029..6ef10a0c3e 100644 --- a/crates/perry-runtime/src/thread_transfer_guard_tests.rs +++ b/crates/perry-runtime/src/thread_transfer_guard_tests.rs @@ -150,3 +150,36 @@ fn serialize_supported_values_still_transfer() { assert_eq!((*back_arr).length, 3); } } + +#[test] +fn uint8array_round_trips_bytes_and_brand() { + unsafe { + let source = crate::buffer::js_uint8array_alloc(4); + std::ptr::copy_nonoverlapping( + [3u8, 5, 8, 255].as_ptr(), + crate::buffer::buffer_data_mut(source), + 4, + ); + let source_bits = JSValue::pointer(source as *const u8).bits(); + + let serialized = serialize_nanbox_for_thread(source_bits); + assert!( + matches!(&serialized, SerializedValue::Uint8Array(bytes) if bytes == &[3, 5, 8, 255]), + "Uint8Array must serialize with its bytes, got {serialized:?}" + ); + assert_eq!(first_unsupported_transfer_type(&serialized), None); + + let result_bits = deserialize_nanbox_on_current_thread(&serialized); + let result = (result_bits & POINTER_MASK) as *const crate::buffer::BufferHeader; + assert_ne!( + result, source, + "structured clone must allocate fresh storage" + ); + assert!(crate::buffer::is_uint8array_buffer(result as usize)); + assert_eq!((*result).length, 4); + assert_eq!( + std::slice::from_raw_parts(crate::buffer::buffer_data(result), 4), + &[3, 5, 8, 255] + ); + } +} diff --git a/crates/perry/tests/issue_worker_threads_cross_thread_delivery.rs b/crates/perry/tests/issue_worker_threads_cross_thread_delivery.rs index 747bce9167..7d2d23ed3a 100644 --- a/crates/perry/tests/issue_worker_threads_cross_thread_delivery.rs +++ b/crates/perry/tests/issue_worker_threads_cross_thread_delivery.rs @@ -216,3 +216,111 @@ worker.onmessage = (ev: any) => { "ready 1 web-env\nready 2 web-env\nreply 8509 8509 true\ndone\n" ); } + +/// OpenCode mixes the browser/Bun Worker surface in the parent with Node's +/// `parentPort` surface in OpenTUI's parser worker. Its RPC payloads also carry +/// byte arrays, and its TUI server worker receives a complete `process.env` +/// snapshot. Exercise that exact boundary plus the SIGUSR2 reload and SIGINT +/// shutdown sequence rather than testing the two Worker API shapes only in +/// isolation. +#[cfg(unix)] +#[test] +fn global_worker_interops_with_parent_port_and_uint8array() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("parser-worker.ts"), + r#" +import { isMainThread, parentPort } from "node:worker_threads"; + +parentPort!.on("message", (message: any) => { + if (message.method === "reload" || message.method === "shutdown") { + parentPort!.postMessage({ + type: "rpc.result", + id: message.id, + result: message.method, + }); + return; + } + const input = message.input as Uint8Array; + parentPort!.postMessage({ + type: "rpc.result", + id: message.id, + env: process.env.OPENCODE_WORKER_TOKEN, + workerThread: !isMainThread, + inputBrand: input instanceof Uint8Array, + payload: new Uint8Array([input[2], input[1], input[0], 255]), + }); +}); +parentPort!.postMessage({ type: "ready" }); +"#, + ) + .expect("write parser worker"); + + let stdout = compile_and_run( + dir.path(), + r#" +setTimeout(() => { console.log("TIMEOUT"); process.exit(2); }, 8000); +process.env.OPENCODE_WORKER_TOKEN = "copied-env"; +const worker = new Worker(new URL("./parser-worker.ts", import.meta.url), { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), +}); +const reload = () => worker.postMessage({ type: "rpc.request", method: "reload", id: 10104 }); +const shutdown = () => worker.postMessage({ type: "rpc.request", method: "shutdown", id: 10105 }); +process.on("SIGUSR2", reload); +process.on("SIGINT", shutdown); +worker.onerror = (event: any) => { + console.log("worker-error", event.message); + process.exit(3); +}; +worker.on("exit", (code: number) => console.log("exit", code)); +worker.onmessage = (event: any) => { + const message = event.data; + if (message.type === "ready") { + worker.postMessage({ + type: "rpc.request", + method: "roundTrip", + id: 10103, + input: new Uint8Array([3, 5, 8]), + }); + return; + } + if (message.id === 10104) { + console.log("signal", message.result); + process.kill(process.pid, "SIGINT"); + return; + } + if (message.id === 10105) { + console.log("signal", message.result); + process.off("SIGUSR2", reload); + process.off("SIGINT", shutdown); + worker.terminate().then((code: number) => { + console.log("terminated", code); + process.exit(0); + }); + return; + } + const bytes = message.payload as Uint8Array; + console.log( + "reply", + message.id, + message.env, + message.workerThread, + message.inputBrand, + bytes instanceof Uint8Array, + bytes.length, + bytes[0], + bytes[1], + bytes[2], + bytes[3], + ); + process.kill(process.pid, "SIGUSR2"); +}; +"#, + ); + assert_eq!( + stdout, + "reply 10103 copied-env true true true 4 8 5 3 255\nsignal reload\nsignal shutdown\nexit 1\nterminated 1\n" + ); +} From df8b26aecbbf1ff94fdfe95c94ddd2600dfbc1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:49:39 +0200 Subject: [PATCH 07/36] docs: number changelog fragment for PR 10133 (cherry picked from commit 0af4392f0a03c42560c3428ee00af2cc3b29a86f) --- .../{10103-worker-uint8array.md => 10133-worker-uint8array.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10103-worker-uint8array.md => 10133-worker-uint8array.md} (100%) diff --git a/changelog.d/10103-worker-uint8array.md b/changelog.d/10133-worker-uint8array.md similarity index 100% rename from changelog.d/10103-worker-uint8array.md rename to changelog.d/10133-worker-uint8array.md From fbbc2295645a4afa38d01635124c9cda89631570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 16:08:15 +0200 Subject: [PATCH 08/36] docs: clarify Uint8Array clone semantics (cherry picked from commit e092d257911292267837eaf336f41cf22b503286) --- changelog.d/10133-worker-uint8array.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/10133-worker-uint8array.md b/changelog.d/10133-worker-uint8array.md index 9a8d7dc649..71150824ed 100644 --- a/changelog.d/10133-worker-uint8array.md +++ b/changelog.d/10133-worker-uint8array.md @@ -1,3 +1,3 @@ ### Fixed -- Preserve `Uint8Array` bytes and identity across native Worker structured-clone boundaries, including a Web-style parent Worker talking to a `node:worker_threads` `parentPort` child (#10103). +- Preserve `Uint8Array` bytes and constructor brand across native Worker structured-clone boundaries, including a Web-style parent Worker talking to a `node:worker_threads` `parentPort` child (#10103). From 2ba85bb7b0b08d840a7ae5ae8ed20180732b03e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:43:35 +0200 Subject: [PATCH 09/36] perf(runtime): streamline DataView numeric setters (cherry picked from commit ee093f3346d303a4497874c098d3f9941969254c) --- benchmarks/issue-10089/README.md | 111 ++++++++++ benchmarks/issue-10089/after.json | 202 ++++++++++++++++++ benchmarks/issue-10089/artifacts.json | 63 ++++++ .../issue-10089/attribution-baseline.json | 202 ++++++++++++++++++ .../attribution-no-handle-scope.json | 202 ++++++++++++++++++ .../attribution-no-view-propagation.json | 202 ++++++++++++++++++ benchmarks/issue-10089/before.json | 202 ++++++++++++++++++ benchmarks/issue-10089/binary-dataview-get.ts | 97 +++++++++ benchmarks/issue-10089/binary-dataview-set.ts | 97 +++++++++ .../diagnostic-no-handle-scope.patch | 27 +++ .../diagnostic-no-view-propagation.patch | 20 ++ benchmarks/issue-10089/measure.py | 113 ++++++++++ crates/perry-runtime/src/buffer/dataview.rs | 151 ++++++++----- crates/perry-runtime/src/buffer/from.rs | 2 +- crates/perry-runtime/src/buffer/view.rs | 44 ++++ crates/perry-runtime/src/buffer/view_tests.rs | 30 +++ ...est_gap_10089_dataview_setter_fast_path.ts | 133 ++++++++++++ 17 files changed, 1845 insertions(+), 53 deletions(-) create mode 100644 benchmarks/issue-10089/README.md create mode 100644 benchmarks/issue-10089/after.json create mode 100644 benchmarks/issue-10089/artifacts.json create mode 100644 benchmarks/issue-10089/attribution-baseline.json create mode 100644 benchmarks/issue-10089/attribution-no-handle-scope.json create mode 100644 benchmarks/issue-10089/attribution-no-view-propagation.json create mode 100644 benchmarks/issue-10089/before.json create mode 100644 benchmarks/issue-10089/binary-dataview-get.ts create mode 100644 benchmarks/issue-10089/binary-dataview-set.ts create mode 100644 benchmarks/issue-10089/diagnostic-no-handle-scope.patch create mode 100644 benchmarks/issue-10089/diagnostic-no-view-propagation.patch create mode 100644 benchmarks/issue-10089/measure.py create mode 100644 test-files/test_gap_10089_dataview_setter_fast_path.ts diff --git a/benchmarks/issue-10089/README.md b/benchmarks/issue-10089/README.md new file mode 100644 index 0000000000..6e59899360 --- /dev/null +++ b/benchmarks/issue-10089/README.md @@ -0,0 +1,111 @@ +# DataView numeric setter fast path (#10089) + +`binary-dataview-set.ts` and `binary-dataview-get.ts` are the unchanged, +standalone reproducers embedded in issue #10089. Their seeded setup, minimum +200 ms/five-run warmup, seven samples of at least 20 ms, median calculation, +fresh-input policy, and per-invocation checksum checks are preserved. + +Measured on 2026-09-12 on Linux x86_64 with an AMD Ryzen 7 7700X (16 logical +CPUs), Node 26.5.1, LLVM 22.1.8, and the pinned nightly-2026-08-20 Rust +toolchain. Before is current main `50e08e91dd6a54d9d9210c43a5d36c86d880d144` +(Perry 0.5.1539); after is this change on the same commit. Both builds used +`--release --locked`, `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16`, the same package +set, and matching compiler/runtime/stdlib archives. Compilation used +`--no-auto-optimize` with `PERRY_RUNTIME_DIR` pointing at those archives. Runs +were serialized. Source and executable hashes are in `artifacts.json`. + +## Current-main comparison + +Times are median milliseconds per workload invocation. The setter workload +writes and immediately reads every value; the getter control only reads. Every +Perry checksum matched Node at every size. + +| n | Before Perry setter | After Perry setter | After Node setter | Before ratio | After ratio | After getter ratio | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 0.006030 | 0.005401 | 0.000269 | 22.50x | 20.06x | 11.51x | +| 1,000 | 0.058436 | 0.052417 | 0.002511 | 23.36x | 20.87x | 12.15x | +| 10,000 | 0.581095 | 0.541799 | 0.024828 | 23.51x | 21.82x | 12.50x | +| 100,000 | 5.730139 | 5.177306 | 0.247883 | 23.26x | 20.89x | 12.55x | +| 1,000,000 | 58.591890 | 51.871222 | 2.477711 | 23.70x | 20.94x | 12.46x | + +The Perry setter workload improves by 6.8–11.5%. Its ratio is 1.66–1.75 times +the getter control, rather than the issue's original 6.68 times at 1M. The +remaining difference includes a second accessor per iteration and numeric +wrapping/storage; the getter baseline itself remains out of scope. Raw results +are `before.json` and `after.json`. + +Least-squares log(time)/log(n) slopes remain linear: before setter Perry 0.997, +Node 0.992; after setter Perry 0.996, Node 0.992. After getter slopes are Perry +0.999 and Node 0.991. + +## Pre-change attribution + +The two original costs were measured independently on the issue's pinned +revision `9495bfc95e2afcfb5a7cb535e440e61ec0722cb1`, before changing behavior. +The no-handle-scope patch is a timing diagnostic only. The no-propagation patch +redirects the write to the canonical shared backing before removing the old +reverse-table propagation, preserving checksums and modeling the storage design +that subsequently landed in #10071. + +| n | Historical baseline | No handle scope | Direct backing/no propagation | Getter control | +|---:|---:|---:|---:|---:| +| 100 | 0.006275 | 0.006034 (-3.8%) | 0.005392 (-14.1%) | 0.002951 | +| 1,000 | 0.062615 | 0.059653 (-4.7%) | 0.053234 (-15.0%) | 0.029187 | +| 10,000 | 0.618650 | 0.592610 (-4.2%) | 0.529090 (-14.5%) | 0.290650 | +| 100,000 | 6.176727 | 5.909852 (-4.3%) | 5.307551 (-14.1%) | 2.906022 | +| 1,000,000 | 51.473179 | 48.430062 (-5.9%) | 51.179968 (-0.6%) | 29.168526 | + +Across the stable 100–100k range, propagation/view-table work was consistently +the dominant extra setter cost, 3.2–3.6 times the handle-scope cost. The 1M +propagation row reproduces the threshold anomaly already called out in the +issue and is not used to reverse the four-size attribution. Getter controls at +100k were 2.9060, 2.8957, and 2.9139 ms across the three builds. + +#10071 landed at `1ae0f84497` between the pinned and current-main measurements. +It made views share canonical backing storage and deleted the reverse-table +propagation call. This change removes the remaining setter-only work: DataView +construction caches the stable backing byte pointer, and calls whose offset and +value are already Numbers neither probe `VIEW_REGISTRY` nor publish a transient +GC handle. Coercing and BigInt calls retain the handle and reload the receiver +after user code. + +Raw attribution data and the exact diagnostic patches are committed alongside +the current-main comparison. + +## Reproduce + +From the checkout being measured: + +```sh +export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 +cargo build --release --locked \ + -p perry -p perry-runtime-static -p perry-stdlib-static +export PERRY_RUNTIME_DIR="$PWD/target/release" +target/release/perry compile \ + benchmarks/issue-10089/binary-dataview-set.ts \ + --no-auto-optimize -o /tmp/binary-dataview-set +target/release/perry compile \ + benchmarks/issue-10089/binary-dataview-get.ts \ + --no-auto-optimize -o /tmp/binary-dataview-get +python3 benchmarks/issue-10089/measure.py \ + --set-app /tmp/binary-dataview-set \ + --get-app /tmp/binary-dataview-get \ + --node node \ + --output /tmp/results.json +``` + +Do not run timed processes or builds concurrently. Rebuild all three packages +when changing checkouts. + +## Semantic and GC validation + +`test_gap_10089_dataview_setter_fast_path.ts` covers both byte orders and all +numeric kinds, byte-level endianness, out-of-range and negative offsets, +ToNumber strings/objects/abrupt completion, BigInt/Number type errors, +ToBigInt-before-bounds ordering, detach during coercion, BigInt round trips, and +DataView/Uint8Array writes in both directions. Its coercion callback allocates, +calls `gc()`, and runs under forced evacuation, from-space protection, and +evacuation verification. Its output matches Node 26.5.1 byte-for-byte. + +The focused runtime test also checks that a windowed DataView stores the exact +backing pointer in its private payload and writes through it. diff --git a/benchmarks/issue-10089/after.json b/benchmarks/issue-10089/after.json new file mode 100644 index 0000000000..be691b463e --- /dev/null +++ b/benchmarks/issue-10089/after.json @@ -0,0 +1,202 @@ +[ + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0002692393786014151, + "runs": 519464, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.005400798056154336, + "runs": 25292, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0025114105976884555, + "runs": 55683, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.052416999999997826, + "runs": 2652, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.024828232009928293, + "runs": 5642, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.5417989189189127, + "runs": 260, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.2478825555555555, + "runs": 567, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 5.177306000000016, + "runs": 28, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.477711222222221, + "runs": 63, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 51.87122199999999, + "runs": 7, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0002671888209047091, + "runs": 508380, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.003074229941592527, + "runs": 45542, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0024895822753297427, + "runs": 51907, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.030258390317700595, + "runs": 4629, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.024301608748480617, + "runs": 5313, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.3037311515151545, + "runs": 464, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.2424219397590394, + "runs": 581, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 3.041258000000003, + "runs": 49, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.4348914444444367, + "runs": 63, + "checksum": 454838306 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 30.34673700000002, + "runs": 7, + "checksum": 454838306 + } +] diff --git a/benchmarks/issue-10089/artifacts.json b/benchmarks/issue-10089/artifacts.json new file mode 100644 index 0000000000..5614aa2c2b --- /dev/null +++ b/benchmarks/issue-10089/artifacts.json @@ -0,0 +1,63 @@ +{ + "date": "2026-09-12", + "host": { + "os": "Linux 6.17.0-23-generic x86_64", + "cpu": "AMD Ryzen 7 7700X 8-Core Processor", + "logical_cpus": 16 + }, + "toolchain": { + "node": "v26.5.1", + "node_sha256": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", + "rustc": "1.100.0-nightly (f7d782a3b 2026-08-19)", + "cargo": "1.100.0-nightly (514c56dd7 2026-08-19)", + "llvm": "22.1.8" + }, + "build": { + "profile": "release", + "locked": true, + "codegen_units": 16, + "auto_optimize": false, + "packages": [ + "perry", + "perry-runtime-static", + "perry-stdlib-static" + ] + }, + "sources": { + "binary-dataview-set.ts": "c01d0c04e4e99fcc6725ee83d1a70cee6647217110c95a850d93ad98128375d3", + "binary-dataview-get.ts": "9693aea9afe91fb2ce471aee496fe3e883482a61e304400a1436d7cb411d32e8" + }, + "builds": { + "attribution_baseline": { + "commit": "9495bfc95e2afcfb5a7cb535e440e61ec0722cb1", + "set_executable_sha256": "90f9ab906bb0df83f34fcb72f51efe06aec72c78474371ac3f003ada3826e5c4", + "get_executable_sha256": "9323e511ab8bb320c50895b97af9f15468792b446c0f81adbcded234c8ecbd91" + }, + "attribution_no_handle_scope": { + "commit": "9495bfc95e2afcfb5a7cb535e440e61ec0722cb1", + "patch": "diagnostic-no-handle-scope.patch", + "set_executable_sha256": "d7cd59357fc0792d53a9ff2bb69b74a741fd264123fe5bbf608b95b738e3abb7", + "get_executable_sha256": "54584620ec9285184ab06a69eeee4665c72f5d3c1abdb840feebde03bb7c7ce5" + }, + "attribution_no_view_propagation": { + "commit": "9495bfc95e2afcfb5a7cb535e440e61ec0722cb1", + "patch": "diagnostic-no-view-propagation.patch", + "set_executable_sha256": "2011401cade07d244a6ce6d92af4dd866376c157c5ade98920e6cbb102598df0", + "get_executable_sha256": "3ff30ca899c87c73643f304d5a879d53fd00b4b984bd0e5c49ef9e28faee07c9" + }, + "before": { + "commit": "50e08e91dd6a54d9d9210c43a5d36c86d880d144", + "set_executable_sha256": "aec304da5fa6683767c99ab62477d5a4b381ff0e85d5112a00da4e7c703d16da", + "get_executable_sha256": "ffd6bc041b0c615d3c332ebc0b602361bbd573b6bd1f73ae0748baba1207743d" + }, + "after": { + "commit": "50e08e91dd6a54d9d9210c43a5d36c86d880d144 plus working-tree change", + "set_executable_sha256": "ebdff93636b9326c526a29defb0235bff0e15548ccd7c40a834621f118aafa3b", + "get_executable_sha256": "4aac90b7e1aee138f907b2724032e4af6a97a1643e3c0933928e8cf33e59cfae", + "gap_executable_sha256": "4327c13fe7530f6fed629f405b83eed40db29d43ffe9e06a945b15bbd9125e1d", + "perry_sha256": "61b10fe2ceacdcc13ad6f5756eb2a22138e91542aa2c51582ceebc6374b1ee53", + "runtime_archive_sha256": "d811de0f23dadd9601c6821a7f81969f5d40ecb0af27001c5c3dce665648cf51", + "stdlib_archive_sha256": "f4515aa0e61db81a0480e164f910c1ae580eb80699acb9a0fbb725e5f68e89ca" + } + } +} diff --git a/benchmarks/issue-10089/attribution-baseline.json b/benchmarks/issue-10089/attribution-baseline.json new file mode 100644 index 0000000000..726487b2aa --- /dev/null +++ b/benchmarks/issue-10089/attribution-baseline.json @@ -0,0 +1,202 @@ +[ + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0002671258013672751, + "runs": 523745, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.006274850062734695, + "runs": 21797, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0024977506243751683, + "runs": 55992, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0626153718750011, + "runs": 2223, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.024648422413793742, + "runs": 5684, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.6186502121211968, + "runs": 231, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.24531021951218174, + "runs": 574, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 6.176727250000084, + "runs": 28, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.462191444444392, + "runs": 62, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 51.473178999999845, + "runs": 7, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0002587780739322494, + "runs": 519664, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0029513823225615853, + "runs": 47152, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0024044314739119863, + "runs": 52759, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.029187157434400306, + "runs": 4801, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.02395959041916093, + "runs": 5366, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.2906503623188428, + "runs": 483, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.237573952941179, + "runs": 595, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 2.9060215714285795, + "runs": 49, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.377274000000006, + "runs": 63, + "checksum": 454838306 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 29.168525999999986, + "runs": 7, + "checksum": 454838306 + } +] diff --git a/benchmarks/issue-10089/attribution-no-handle-scope.json b/benchmarks/issue-10089/attribution-no-handle-scope.json new file mode 100644 index 0000000000..764abe7898 --- /dev/null +++ b/benchmarks/issue-10089/attribution-no-handle-scope.json @@ -0,0 +1,202 @@ +[ + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.000267853672255898, + "runs": 522604, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.006033606636494265, + "runs": 22284, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0024940461346632994, + "runs": 55995, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.05965330654762267, + "runs": 2340, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.024723711990112383, + "runs": 5664, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.5926102352941364, + "runs": 239, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.24635226829268253, + "runs": 574, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 5.909851500000002, + "runs": 28, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.465058111111072, + "runs": 63, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 48.430062000000135, + "runs": 7, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.00026624930109959716, + "runs": 507112, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.002917865061998543, + "runs": 47994, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0024790539167077515, + "runs": 51710, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.02878656690647388, + "runs": 4865, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.024621353013529843, + "runs": 5613, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.28797942857143094, + "runs": 490, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.24566884146341156, + "runs": 574, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 2.8957324285714288, + "runs": 49, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.4592393333333415, + "runs": 63, + "checksum": 454838306 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 28.78403000000003, + "runs": 7, + "checksum": 454838306 + } +] diff --git a/benchmarks/issue-10089/attribution-no-view-propagation.json b/benchmarks/issue-10089/attribution-no-view-propagation.json new file mode 100644 index 0000000000..42621b99ce --- /dev/null +++ b/benchmarks/issue-10089/attribution-no-view-propagation.json @@ -0,0 +1,202 @@ +[ + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.00026791162996263237, + "runs": 522573, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.005391617789760156, + "runs": 25351, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0024958236835534264, + "runs": 56108, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.05323446010638031, + "runs": 2635, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.024650150246305614, + "runs": 5684, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.529089578947366, + "runs": 266, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.24529362195122448, + "runs": 573, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 5.307550500000048, + "runs": 28, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.46376277777775, + "runs": 63, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 51.17996800000003, + "runs": 7, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0002584621160233231, + "runs": 519704, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0029523673800744663, + "runs": 47418, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.002428678081360012, + "runs": 52880, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.029149784570596676, + "runs": 4807, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.02394625956937798, + "runs": 5373, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.3649505636363668, + "runs": 385, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.2375824588235312, + "runs": 595, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 2.9139227142857345, + "runs": 49, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.3774148888888837, + "runs": 63, + "checksum": 454838306 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 29.158057000000042, + "runs": 7, + "checksum": 454838306 + } +] diff --git a/benchmarks/issue-10089/before.json b/benchmarks/issue-10089/before.json new file mode 100644 index 0000000000..f9832871d6 --- /dev/null +++ b/benchmarks/issue-10089/before.json @@ -0,0 +1,202 @@ +[ + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0002679783875765051, + "runs": 521868, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.006029571299367893, + "runs": 23011, + "checksum": 53205556 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.002501483116558659, + "runs": 56002, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.05843568804665129, + "runs": 2398, + "checksum": 509007827 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.024721538271608463, + "runs": 5669, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.5810946571428368, + "runs": 245, + "checksum": 6182819 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.24638769512195013, + "runs": 574, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 100000, + "ms_per_run": 5.730139000000008, + "runs": 28, + "checksum": 64481668 + }, + { + "workload": "binary-dataview-set", + "engine": "node", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.4717743333333653, + "runs": 63, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-set", + "engine": "perry", + "name": "binary-dataview-set", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 58.59188999999992, + "runs": 7, + "checksum": 455838306 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0002587603762355714, + "runs": 520913, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100, + "ms_per_run": 0.0030928861914337803, + "runs": 45286, + "checksum": 53205456 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.0024144640270400974, + "runs": 52888, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000, + "ms_per_run": 0.030480231354642215, + "runs": 4592, + "checksum": 509006827 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.023937772727272388, + "runs": 5379, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 10000, + "ms_per_run": 0.30393949999999975, + "runs": 462, + "checksum": 6172819 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 0.2398608690476174, + "runs": 588, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 100000, + "ms_per_run": 3.0356365714285647, + "runs": 49, + "checksum": 64381668 + }, + { + "workload": "binary-dataview-get", + "engine": "node", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 2.3884473333333336, + "runs": 63, + "checksum": 454838306 + }, + { + "workload": "binary-dataview-get", + "engine": "perry", + "name": "binary-dataview-get", + "category": "binary-node", + "n": 1000000, + "ms_per_run": 30.850653000000023, + "runs": 7, + "checksum": 454838306 + } +] diff --git a/benchmarks/issue-10089/binary-dataview-get.ts b/benchmarks/issue-10089/binary-dataview-get.ts new file mode 100644 index 0000000000..46dc130538 --- /dev/null +++ b/benchmarks/issue-10089/binary-dataview-get.ts @@ -0,0 +1,97 @@ +// @runtime {"name": "binary-dataview-get", "category": "binary-node", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/buffer/dataview.rs", "function": "js_data_view_get"}], "hypothesis": "Each accessor validates/coerces offsets and dispatches the requested numeric kind and byte order.", "notes": "n uint32 accesses. The setter case immediately reads each written value to validate writes; getter-only control separates that cost.", "asynchronous": false, "output_stderr": true, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): {view: DataView, values: number[]} { + const view = new DataView(new ArrayBuffer(n * 4)); + const values = numbers(n); + for (let i = 0; i < n; i++) view.setUint32(i * 4, values[i], true); + return {view, values}; +} +function run(input: {view: DataView, values: number[]}): number { + let h = 0; + for (let i = 0; i < input.values.length; i++) { + + h = (h + input.view.getUint32(i * 4, true)) % 1000000007; + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.error(JSON.stringify({name: "binary-dataview-get", category: "binary-node", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/issue-10089/binary-dataview-set.ts b/benchmarks/issue-10089/binary-dataview-set.ts new file mode 100644 index 0000000000..f0bb2a1db3 --- /dev/null +++ b/benchmarks/issue-10089/binary-dataview-set.ts @@ -0,0 +1,97 @@ +// @runtime {"name": "binary-dataview-set", "category": "binary-node", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/buffer/dataview.rs", "function": "js_data_view_set"}], "hypothesis": "Each accessor validates/coerces offsets and dispatches the requested numeric kind and byte order.", "notes": "n uint32 accesses. The setter case immediately reads each written value to validate writes; getter-only control separates that cost.", "asynchronous": false, "output_stderr": true, "fresh_input": true} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): {view: DataView, values: number[]} { + const view = new DataView(new ArrayBuffer(n * 4)); + const values = numbers(n); + for (let i = 0; i < n; i++) view.setUint32(i * 4, values[i], true); + return {view, values}; +} +function run(input: {view: DataView, values: number[]}): number { + let h = 0; + for (let i = 0; i < input.values.length; i++) { + input.view.setUint32(i * 4, input.values[i] + 1, true); + h = (h + input.view.getUint32(i * 4, true)) % 1000000007; + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = setup(n); + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = setup(n); + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.error(JSON.stringify({name: "binary-dataview-set", category: "binary-node", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/issue-10089/diagnostic-no-handle-scope.patch b/benchmarks/issue-10089/diagnostic-no-handle-scope.patch new file mode 100644 index 0000000000..eee9f52c25 --- /dev/null +++ b/benchmarks/issue-10089/diagnostic-no-handle-scope.patch @@ -0,0 +1,27 @@ +diff --git a/crates/perry-runtime/src/buffer/dataview.rs b/crates/perry-runtime/src/buffer/dataview.rs +index 71727b2d24..0000000000 100644 +--- a/crates/perry-runtime/src/buffer/dataview.rs ++++ b/crates/perry-runtime/src/buffer/dataview.rs +@@ -271,8 +271,7 @@ pub fn js_data_view_set( + kind: DataViewKind, + little: bool, + ) -> f64 { +- let scope = crate::gc::RuntimeHandleScope::new(); +- let buf_handle = scope.root_nanbox_f64(buf_f64); ++ let buf = unbox_buffer_ptr(buf_f64.to_bits()) as *mut BufferHeader; + let offset = to_byte_offset(offset_value); + if kind.is_bigint() { + // SetViewValue for a BigInt accessor: `ToBigInt(value)` (a Number throws +@@ -285,12 +284,10 @@ pub fn js_data_view_set( + } else { + raw.to_be_bytes() + }; +- let buf = unbox_buffer_ptr(buf_handle.get_nanbox_f64().to_bits()) as *mut BufferHeader; + unsafe { write_bytes(buf, offset, &b) }; + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let n = to_number(value); +- let buf = unbox_buffer_ptr(buf_handle.get_nanbox_f64().to_bits()) as *mut BufferHeader; + unsafe { + match kind { + DataViewKind::BigInt64 | DataViewKind::BigUint64 => unreachable!(), diff --git a/benchmarks/issue-10089/diagnostic-no-view-propagation.patch b/benchmarks/issue-10089/diagnostic-no-view-propagation.patch new file mode 100644 index 0000000000..62109d2c18 --- /dev/null +++ b/benchmarks/issue-10089/diagnostic-no-view-propagation.patch @@ -0,0 +1,20 @@ +diff --git a/crates/perry-runtime/src/buffer/dataview.rs b/crates/perry-runtime/src/buffer/dataview.rs +index 71727b2d24..0000000000 100644 +--- a/crates/perry-runtime/src/buffer/dataview.rs ++++ b/crates/perry-runtime/src/buffer/dataview.rs +@@ -182,14 +182,8 @@ unsafe fn write_bytes(buf: *mut BufferHeader, offset: i64, bytes: &[u8]) { + if offset + (bytes.len() as i64) > len { + throw_dataview_oob(); + } +- let base = buffer_data_mut(buf).add(offset as usize); ++ let base = super::view::resolve_data_ptr(buf).add(offset as usize) as *mut u8; + ptr::copy_nonoverlapping(bytes.as_ptr(), base, bytes.len()); +- super::view::propagate_written_range_from_receiver( +- buf as usize, +- offset as u32, +- base, +- bytes.len() as u32, +- ); + } + + /// `DataView.prototype.get(byteOffset, littleEndian?)`. diff --git a/benchmarks/issue-10089/measure.py b/benchmarks/issue-10089/measure.py new file mode 100644 index 0000000000..b120b826cc --- /dev/null +++ b/benchmarks/issue-10089/measure.py @@ -0,0 +1,113 @@ +"""Run the unchanged #10089 DataView workloads serially against Node and Perry.""" + +import argparse +import json +import math +import os +from pathlib import Path +import subprocess + + +SIZES = [100, 1000, 10000, 100000, 1000000] + + +def slope(points): + if len(points) < 2: + return None + xs = [math.log(n) for n, _ in points] + ys = [math.log(ms) for _, ms in points] + mean_x = sum(xs) / len(xs) + mean_y = sum(ys) / len(ys) + return sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys)) / sum( + (x - mean_x) ** 2 for x in xs + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--set-app", type=Path, required=True) + parser.add_argument("--get-app", type=Path, required=True) + parser.add_argument("--node", default="node") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--allow-mismatch", + action="store_true", + help="continue diagnostic sweeps whose temporary patch breaks coherency", + ) + args = parser.parse_args() + root = Path(__file__).resolve().parent + workloads = { + "binary-dataview-set": args.set_app.resolve(), + "binary-dataview-get": args.get_app.resolve(), + } + env = dict(os.environ, TZ="UTC", LC_ALL="en_US.UTF-8") + rows = [] + points = {} + for name, app in workloads.items(): + points[name] = {"node": [], "perry": []} + stopped = set() + for n in SIZES: + pair = {} + commands = { + "node": [args.node, str(root / f"{name}.ts")], + "perry": [str(app)], + } + for engine, command in commands.items(): + if engine in stopped: + continue + try: + proc = subprocess.run( + command + [str(n)], + capture_output=True, + text=True, + timeout=60, + env=env, + ) + if proc.returncode: + row = { + "workload": name, + "engine": engine, + "n": n, + "status": "ERROR", + "code": proc.returncode, + "stderr": proc.stderr, + "stdout": proc.stdout, + } + stopped.add(engine) + else: + result = json.loads(proc.stderr) + row = {"workload": name, "engine": engine, **result} + pair[engine] = result + points[name][engine].append((n, result["ms_per_run"])) + except subprocess.TimeoutExpired: + row = { + "workload": name, + "engine": engine, + "n": n, + "status": "TIMEOUT", + } + stopped.add(engine) + rows.append(row) + print(json.dumps(row), flush=True) + args.output.write_text( + json.dumps(rows, indent=2) + "\n", encoding="utf-8" + ) + if len(pair) == 2 and pair["node"]["checksum"] != pair["perry"]["checksum"]: + message = f"Checksum mismatch for {name} at n={n}: {pair}" + if not args.allow_mismatch: + raise SystemExit(message) + print(message, flush=True) + print( + "slopes", + { + name: {engine: slope(values) for engine, values in engines.items()} + for name, engines in points.items() + }, + flush=True, + ) + if any("status" in row for row in rows): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/crates/perry-runtime/src/buffer/dataview.rs b/crates/perry-runtime/src/buffer/dataview.rs index 860622612c..c17c5970ef 100644 --- a/crates/perry-runtime/src/buffer/dataview.rs +++ b/crates/perry-runtime/src/buffer/dataview.rs @@ -104,6 +104,19 @@ fn throw_dataview_oob() -> ! { super::numeric::throw_dataview_offset_out_of_bounds() } +fn throw_dataview_detached() -> ! { + crate::collection_iter::throw_type_error( + "Cannot perform DataView access on a detached ArrayBuffer", + ) +} + +/// Non-allocating `ToIndex` subset used by the numeric setter fast path. +#[inline(always)] +fn numeric_byte_offset(value: f64) -> Option { + (value >= 0.0 && value <= 9_007_199_254_740_991.0 && value.trunc() == value) + .then_some(value as i64) +} + #[inline] /// `ToIndex(byteOffset)` for `GetViewValue`/`SetViewValue`: ToNumber → /// ToIntegerOrInfinity → range-check `[0, 2^53-1]`. A Symbol or object byteOffset @@ -118,8 +131,8 @@ fn to_byte_offset(value: f64) -> i64 { // Fast path (#6386): a non-NaN f64 is by NaN-boxing construction a // genuine Number (every tag pattern is a NaN payload), so a valid // integral index needs no coercion machinery at all. - if value >= 0.0 && value <= 9_007_199_254_740_991.0 && value.trunc() == value { - return value as i64; + if let Some(offset) = numeric_byte_offset(value) { + return offset; } if crate::value::JSValue::from_bits(value.to_bits()).is_bigint() { crate::collection_iter::throw_type_error("Cannot convert a BigInt value to a number"); @@ -140,12 +153,17 @@ fn to_byte_offset(value: f64) -> i64 { /// step order). A BigInt accessor takes the `to_bigint_raw_or_throw` path instead. #[inline] fn to_number(value: f64) -> f64 { - // A non-NaN f64 is by NaN-boxing construction already a Number (#6386); - // every non-Number value (and boxed int32) carries a NaN tag pattern and - // takes the full coercion. - if !value.is_nan() { + let js_value = crate::value::JSValue::from_bits(value.to_bits()); + // Includes every IEEE-754 NaN encoding that is not in Perry's tag band. + if js_value.is_number() { return value; } + // DataView SetViewValue uses the abstract ToNumber operation, which rejects + // BigInt. `js_number_coerce` also serves explicit Number(), where conversion + // from BigInt is allowed, so reject it at this call site. + if js_value.is_bigint() { + crate::collection_iter::throw_type_error("Cannot convert a BigInt value to a number"); + } crate::builtins::js_number_coerce(value) } @@ -175,13 +193,74 @@ unsafe fn write_bytes(buf: *mut BufferHeader, offset: i64, bytes: &[u8]) { throw_dataview_oob(); } let len = (*buf).length as i64; + // Detach zeroes every registered view's length before decommitting backing + // pages. Keep the common non-empty path table-free; only a zero-length view + // needs to distinguish detached TypeError from ordinary RangeError. + if len == 0 && super::detach::is_detached_buffer(super::view::backing_of(buf as usize)) { + throw_dataview_detached(); + } if offset + (bytes.len() as i64) > len { throw_dataview_oob(); } - let base = buffer_data_mut(buf).add(offset as usize); + let base = super::view::data_view_data_ptr(buf).add(offset as usize); ptr::copy_nonoverlapping(bytes.as_ptr(), base, bytes.len()); } +/// Store an already-coerced Number. This path contains no allocation or user +/// callback; its only call, `write_bytes`, performs bounds checks and a memcpy +/// through the stable pointer cached by `DataView` construction. +unsafe fn write_number( + buf: *mut BufferHeader, + offset: i64, + n: f64, + kind: DataViewKind, + little: bool, +) { + match kind { + DataViewKind::BigInt64 | DataViewKind::BigUint64 => unreachable!(), + DataViewKind::Int8 | DataViewKind::Uint8 => { + // ToUint8 / ToInt8 wrap to the same byte; store identically. + let byte = wrap_to_u64(n, 8) as u8; + write_bytes(buf, offset, &[byte]); + } + DataViewKind::Int16 | DataViewKind::Uint16 => { + let v = wrap_to_u64(n, 16) as u16; + let bytes = if little { + v.to_le_bytes() + } else { + v.to_be_bytes() + }; + write_bytes(buf, offset, &bytes); + } + DataViewKind::Int32 | DataViewKind::Uint32 => { + let v = wrap_to_u64(n, 32) as u32; + let bytes = if little { + v.to_le_bytes() + } else { + v.to_be_bytes() + }; + write_bytes(buf, offset, &bytes); + } + DataViewKind::Float32 => { + let v = n as f32; + let bytes = if little { + v.to_le_bytes() + } else { + v.to_be_bytes() + }; + write_bytes(buf, offset, &bytes); + } + DataViewKind::Float64 => { + let bytes = if little { + n.to_le_bytes() + } else { + n.to_be_bytes() + }; + write_bytes(buf, offset, &bytes); + } + } +} + /// `DataView.prototype.get(byteOffset, littleEndian?)`. /// `buf_f64` is the NaN-boxed DataView (BufferHeader) pointer. pub fn js_data_view_get(buf_f64: f64, offset_value: f64, kind: DataViewKind, little: bool) -> f64 { @@ -271,6 +350,18 @@ pub fn js_data_view_set( kind: DataViewKind, little: bool, ) -> f64 { + // Both inputs are already Numbers and ToIndex is already resolved: no call + // below can allocate, invoke JavaScript, collect, or move/reclaim `buf`. + // Avoid publishing a transient GC root and use the construction-time data + // pointer cache instead of probing VIEW_REGISTRY on every numeric write. + if !kind.is_bigint() && crate::value::JSValue::from_bits(value.to_bits()).is_number() { + if let Some(offset) = numeric_byte_offset(offset_value) { + let buf = unbox_buffer_ptr(buf_f64.to_bits()) as *mut BufferHeader; + unsafe { write_number(buf, offset, value, kind, little) }; + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + } + let scope = crate::gc::RuntimeHandleScope::new(); let buf_handle = scope.root_nanbox_f64(buf_f64); let offset = to_byte_offset(offset_value); @@ -290,51 +381,7 @@ pub fn js_data_view_set( } let n = to_number(value); let buf = unbox_buffer_ptr(buf_handle.get_nanbox_f64().to_bits()) as *mut BufferHeader; - unsafe { - match kind { - DataViewKind::BigInt64 | DataViewKind::BigUint64 => unreachable!(), - DataViewKind::Int8 | DataViewKind::Uint8 => { - // ToUint8 / ToInt8 wrap to the same byte; store identically. - let byte = wrap_to_u64(n, 8) as u8; - write_bytes(buf, offset, &[byte]); - } - DataViewKind::Int16 | DataViewKind::Uint16 => { - let v = wrap_to_u64(n, 16) as u16; - let b = if little { - v.to_le_bytes() - } else { - v.to_be_bytes() - }; - write_bytes(buf, offset, &b); - } - DataViewKind::Int32 | DataViewKind::Uint32 => { - let v = wrap_to_u64(n, 32) as u32; - let b = if little { - v.to_le_bytes() - } else { - v.to_be_bytes() - }; - write_bytes(buf, offset, &b); - } - DataViewKind::Float32 => { - let v = n as f32; - let b = if little { - v.to_le_bytes() - } else { - v.to_be_bytes() - }; - write_bytes(buf, offset, &b); - } - DataViewKind::Float64 => { - let b = if little { - n.to_le_bytes() - } else { - n.to_be_bytes() - }; - write_bytes(buf, offset, &b); - } - } - } + unsafe { write_number(buf, offset, n, kind, little) }; f64::from_bits(crate::value::TAG_UNDEFINED) } diff --git a/crates/perry-runtime/src/buffer/from.rs b/crates/perry-runtime/src/buffer/from.rs index 508403948f..a14a6a4375 100644 --- a/crates/perry-runtime/src/buffer/from.rs +++ b/crates/perry-runtime/src/buffer/from.rs @@ -936,7 +936,7 @@ pub extern "C" fn js_data_view_new(value: f64, offset_value: f64, length_value: // report the right values, including zero-length views at the end. let start = offset as u32; let len = view_len as u32; - let view = super::view::alloc(src, start, len); + let view = super::view::alloc_data_view(src, start, len); mark_as_data_view(view as usize); set_buffer_ab_alias(view as usize, resolve_buffer_ab_alias(addr)); f64::from_bits(crate::value::JSValue::pointer(view as *mut u8).bits()) diff --git a/crates/perry-runtime/src/buffer/view.rs b/crates/perry-runtime/src/buffer/view.rs index 7683c1e65c..8879d12469 100644 --- a/crates/perry-runtime/src/buffer/view.rs +++ b/crates/perry-runtime/src/buffer/view.rs @@ -106,6 +106,50 @@ pub(crate) fn alloc(backing: *const BufferHeader, offset: u32, length: u32) -> * view } +/// Allocate a DataView with one cached native data pointer after its +/// `BufferHeader`. Unlike Buffer/Uint8Array views, DataView byte access always +/// enters a runtime helper, so this private payload is never mistaken for +/// indexed storage. The backing remains owned and traced by `VIEW_REGISTRY`. +/// +/// Buffer allocations and foreign/shared ArrayBuffer storage are non-moving: +/// `buffer_alloc` uses the old arena because native callers retain byte +/// pointers, and foreign/shared backings have the same stable-address contract. +/// The cached interior pointer therefore stays valid until detach, which zeroes +/// the view length before its backing storage can be released. +pub(crate) fn alloc_data_view( + backing: *const BufferHeader, + offset: u32, + length: u32, +) -> *mut BufferHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_raw_const_ptr(backing); + let view = buffer_alloc(std::mem::size_of::() as u32); + unsafe { + (*view).length = length; + owner.with_const_ptr::(|backing| { + register(view as usize, backing as usize, offset); + let data = buffer_data(backing).add(offset as usize); + data_view_cache_slot(view).write(data as usize); + }); + } + view +} + +#[inline(always)] +unsafe fn data_view_cache_slot(view: *mut BufferHeader) -> *mut usize { + (view as *mut u8) + .add(std::mem::size_of::()) + .cast::() +} + +/// Load the stable byte pointer cached by [`alloc_data_view`]. The caller must +/// first bounds-check the DataView and reject a detached backing. +#[inline(always)] +pub(crate) unsafe fn data_view_data_ptr(view: *mut BufferHeader) -> *mut u8 { + debug_assert!((*view).capacity >= std::mem::size_of::() as u32); + data_view_cache_slot(view).read() as *mut u8 +} + fn register(view_ptr: usize, backing_ptr: usize, offset: u32) { let (backing, offset) = lookup(backing_ptr) .map(|parent| (parent.backing, parent.offset + offset)) diff --git a/crates/perry-runtime/src/buffer/view_tests.rs b/crates/perry-runtime/src/buffer/view_tests.rs index 77faf17479..118b8d09cc 100644 --- a/crates/perry-runtime/src/buffer/view_tests.rs +++ b/crates/perry-runtime/src/buffer/view_tests.rs @@ -22,6 +22,36 @@ fn suffix_views_allocate_only_headers_and_share_native_bytes() { } } +#[test] +fn data_view_caches_its_stable_window_pointer_in_the_header_payload() { + let backing = js_array_buffer_new(16); + let backing_value = value(backing); + let view_value = js_data_view_new(backing_value, 4.0, 8.0); + let view = crate::value::JSValue::from_bits(view_value.to_bits()).as_pointer::() + as *mut BufferHeader; + + unsafe { + assert_eq!((*view).length, 8); + assert_eq!((*view).capacity, std::mem::size_of::() as u32); + assert_eq!( + view::data_view_data_ptr(view) as *const u8, + buffer_data(backing).add(4) + ); + } + + js_data_view_set( + view_value, + 0.0, + 0x0102_0304 as f64, + DataViewKind::Uint32, + false, + ); + assert_eq!( + unsafe { std::slice::from_raw_parts(buffer_data(backing).add(4), 4) }, + &[1, 2, 3, 4] + ); +} + #[test] fn overlapping_nested_views_share_every_write_path() { let source = js_buffer_alloc(12, 0); diff --git a/test-files/test_gap_10089_dataview_setter_fast_path.ts b/test-files/test_gap_10089_dataview_setter_fast_path.ts new file mode 100644 index 0000000000..b5f92989f2 --- /dev/null +++ b/test-files/test_gap_10089_dataview_setter_fast_path.ts @@ -0,0 +1,133 @@ +// parity-env: PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 +// #10089: DataView's numeric setter fast path must avoid a runtime handle +// scope without changing coercion order, errors, byte order, or shared views. + +function errorName(label: string, callback: () => void): void { + try { + callback(); + console.log(label, "NO THROW"); + } catch (error) { + console.log(label, (error as Error).name); + } +} + +function numericRoundTrips(little: boolean): string { + const view = new DataView(new ArrayBuffer(64)); + view.setInt8(0, -123); + view.setUint8(1, 250); + view.setInt16(2, -0x1234, little); + view.setUint16(4, 0xabcd, little); + view.setInt32(8, -0x1234567, little); + view.setUint32(12, 0x89abcdef, little); + view.setFloat32(16, 1.5, little); + view.setFloat64(24, -3.25, little); + return [ + view.getInt8(0), + view.getUint8(1), + view.getInt16(2, little), + view.getUint16(4, little), + view.getInt32(8, little), + view.getUint32(12, little), + view.getFloat32(16, little), + view.getFloat64(24, little), + ].join(","); +} + +console.log("numeric big-endian", numericRoundTrips(false)); +console.log("numeric little-endian", numericRoundTrips(true)); + +{ + const buffer = new ArrayBuffer(16); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + view.setUint32(0, 0x01020304, false); + view.setUint32(4, 0x01020304, true); + view.setFloat32(8, 1.5, false); + view.setFloat32(12, 1.5, true); + console.log("endian bytes", Array.from(bytes).join(",")); +} + +{ + const buffer = new ArrayBuffer(16); + const words = new Uint8Array(buffer); + const view = new DataView(buffer, 4, 8); + view.setUint32(0, 0x01020304, false); + const throughTypedArray = Array.from(words.slice(4, 8)).join(","); + words.set([0x05, 0x06, 0x07, 0x08], 8); + console.log( + "shared views", + throughTypedArray, + view.getUint32(4, false).toString(16), + ); +} + +{ + const view = new DataView(new ArrayBuffer(8)); + view.setUint32(0, "17" as any, true); + view.setUint16(4, { valueOf() { return 0x2345; } } as any, true); + console.log("ToNumber", view.getUint32(0, true), view.getUint16(4, true)); + + errorName("negative offset", () => view.setUint32(-1, 1)); + errorName("out of range", () => view.setUint32(5, 1)); + errorName("throwing valueOf", () => view.setUint32(0, { + valueOf() { throw new Error("coercion marker"); }, + } as any)); + errorName("numeric BigInt", () => view.setUint32(0, 1n as any)); +} + +{ + let valueCalls = 0; + const view = new DataView(new ArrayBuffer(4)); + errorName("negative before value", () => view.setUint32(-1, { + valueOf() { valueCalls++; return 1; }, + } as any)); + console.log("negative value calls", valueCalls); + + errorName("number value before bounds", () => view.setUint32(4, { + valueOf() { valueCalls++; return 1; }, + } as any)); + console.log("number value calls", valueCalls); + + errorName("BigInt Number in bounds", () => view.setBigInt64(0, 1 as any)); + errorName("BigInt Number before bounds", () => view.setBigInt64(8, 1 as any)); + errorName("BigInt string then bounds", () => view.setBigInt64(8, "1" as any)); +} + +{ + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + errorName("detach during ToNumber", () => view.setUint32(0, { + valueOf() { + buffer.transfer(); + return 7; + }, + } as any, true)); +} + +{ + let coercions = 0; + const buffer = new ArrayBuffer(8); + const mirror = new Uint8Array(buffer); + new DataView(buffer).setUint32(0, { + valueOf() { + coercions++; + const pressure: object[] = []; + for (let i = 0; i < 512; i++) pressure.push({ i, text: "move-" + i }); + const collect = (globalThis as any).gc; + if (typeof collect === "function") collect(); + return 0x01020304; + }, + } as any, false); + console.log("moving coercion", coercions, Array.from(mirror.slice(0, 4)).join(",")); +} + +{ + const view = new DataView(new ArrayBuffer(16)); + view.setBigInt64(0, -2n, false); + view.setBigUint64(8, 0xfedcba9876543210n, true); + console.log( + "BigInt endian", + view.getBigInt64(0, false), + view.getBigUint64(8, true).toString(16), + ); +} From 4d78aa328ad1b0e0a92d9f74b377ba31a6884faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:54:53 +0200 Subject: [PATCH 10/36] chore: finalize DataView setter evidence (cherry picked from commit 1770ba6078a24fe66547dfcaf2ec36784c976488) --- benchmarks/issue-10089/README.md | 26 ++++--- benchmarks/issue-10089/after.json | 74 +++++++++---------- benchmarks/issue-10089/artifacts.json | 4 +- .../10134-dataview-setter-fast-path.md | 16 ++++ 4 files changed, 69 insertions(+), 51 deletions(-) create mode 100644 changelog.d/10134-dataview-setter-fast-path.md diff --git a/benchmarks/issue-10089/README.md b/benchmarks/issue-10089/README.md index 6e59899360..8561a51846 100644 --- a/benchmarks/issue-10089/README.md +++ b/benchmarks/issue-10089/README.md @@ -22,21 +22,23 @@ Perry checksum matched Node at every size. | n | Before Perry setter | After Perry setter | After Node setter | Before ratio | After ratio | After getter ratio | |---:|---:|---:|---:|---:|---:|---:| -| 100 | 0.006030 | 0.005401 | 0.000269 | 22.50x | 20.06x | 11.51x | -| 1,000 | 0.058436 | 0.052417 | 0.002511 | 23.36x | 20.87x | 12.15x | -| 10,000 | 0.581095 | 0.541799 | 0.024828 | 23.51x | 21.82x | 12.50x | -| 100,000 | 5.730139 | 5.177306 | 0.247883 | 23.26x | 20.89x | 12.55x | -| 1,000,000 | 58.591890 | 51.871222 | 2.477711 | 23.70x | 20.94x | 12.46x | - -The Perry setter workload improves by 6.8–11.5%. Its ratio is 1.66–1.75 times -the getter control, rather than the issue's original 6.68 times at 1M. The -remaining difference includes a second accessor per iteration and numeric +| 100 | 0.006030 | 0.005485 | 0.000273 | 22.50x | 20.10x | 11.50x | +| 1,000 | 0.058436 | 0.052530 | 0.002515 | 23.36x | 20.88x | 12.25x | +| 10,000 | 0.581095 | 0.522024 | 0.025047 | 23.51x | 20.84x | 12.27x | +| 100,000 | 5.730139 | 5.185880 | 0.250468 | 23.26x | 20.70x | 12.24x | +| 1,000,000 | 58.591890 | 58.148333 | 2.509861 | 23.70x | 23.17x | 12.25x | + +Across the stable 100–100k range, the Perry setter workload improves by +9.0–10.2% and is 1.70–1.77 times the getter control. The 1M row still improves +by 0.8% but hits the threshold anomaly described in the issue, at 1.90 times +the getter rather than the issue's original 6.68 times. The remaining +difference includes a second accessor per iteration and numeric wrapping/storage; the getter baseline itself remains out of scope. Raw results are `before.json` and `after.json`. -Least-squares log(time)/log(n) slopes remain linear: before setter Perry 0.997, -Node 0.992; after setter Perry 0.996, Node 0.992. After getter slopes are Perry -0.999 and Node 0.991. +Least-squares log(time)/log(n) slopes remain near-linear: before setter Perry +0.997, Node 0.992; after setter Perry 1.005, Node 0.993. After getter slopes are +Perry 0.999 and Node 0.993. ## Pre-change attribution diff --git a/benchmarks/issue-10089/after.json b/benchmarks/issue-10089/after.json index be691b463e..07471c7cc6 100644 --- a/benchmarks/issue-10089/after.json +++ b/benchmarks/issue-10089/after.json @@ -5,8 +5,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100, - "ms_per_run": 0.0002692393786014151, - "runs": 519464, + "ms_per_run": 0.0002728407841431411, + "runs": 513344, "checksum": 53205556 }, { @@ -15,8 +15,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100, - "ms_per_run": 0.005400798056154336, - "runs": 25292, + "ms_per_run": 0.005485234439266762, + "runs": 24992, "checksum": 53205556 }, { @@ -25,8 +25,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000, - "ms_per_run": 0.0025114105976884555, - "runs": 55683, + "ms_per_run": 0.0025154399446612945, + "runs": 55610, "checksum": 509007827 }, { @@ -35,8 +35,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000, - "ms_per_run": 0.052416999999997826, - "runs": 2652, + "ms_per_run": 0.05253031758529973, + "runs": 2665, "checksum": 509007827 }, { @@ -45,8 +45,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 10000, - "ms_per_run": 0.024828232009928293, - "runs": 5642, + "ms_per_run": 0.025046856070088715, + "runs": 5597, "checksum": 6182819 }, { @@ -55,8 +55,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 10000, - "ms_per_run": 0.5417989189189127, - "runs": 260, + "ms_per_run": 0.5220241282051301, + "runs": 273, "checksum": 6182819 }, { @@ -65,8 +65,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100000, - "ms_per_run": 0.2478825555555555, - "runs": 567, + "ms_per_run": 0.2504683250000099, + "runs": 560, "checksum": 64481668 }, { @@ -75,7 +75,7 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100000, - "ms_per_run": 5.177306000000016, + "ms_per_run": 5.185880249999997, "runs": 28, "checksum": 64481668 }, @@ -85,8 +85,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000000, - "ms_per_run": 2.477711222222221, - "runs": 63, + "ms_per_run": 2.5098611250000715, + "runs": 56, "checksum": 455838306 }, { @@ -95,7 +95,7 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000000, - "ms_per_run": 51.87122199999999, + "ms_per_run": 58.148332999999866, "runs": 7, "checksum": 455838306 }, @@ -105,8 +105,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100, - "ms_per_run": 0.0002671888209047091, - "runs": 508380, + "ms_per_run": 0.0002696610398015311, + "runs": 498649, "checksum": 53205456 }, { @@ -115,8 +115,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100, - "ms_per_run": 0.003074229941592527, - "runs": 45542, + "ms_per_run": 0.003101427042952572, + "runs": 45281, "checksum": 53205456 }, { @@ -125,8 +125,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000, - "ms_per_run": 0.0024895822753297427, - "runs": 51907, + "ms_per_run": 0.0024868030585604418, + "runs": 51286, "checksum": 509006827 }, { @@ -135,8 +135,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000, - "ms_per_run": 0.030258390317700595, - "runs": 4629, + "ms_per_run": 0.030473199391173125, + "runs": 4597, "checksum": 509006827 }, { @@ -145,8 +145,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 10000, - "ms_per_run": 0.024301608748480617, - "runs": 5313, + "ms_per_run": 0.024965387780549065, + "runs": 4978, "checksum": 6172819 }, { @@ -155,8 +155,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 10000, - "ms_per_run": 0.3037311515151545, - "runs": 464, + "ms_per_run": 0.3064169090909098, + "runs": 462, "checksum": 6172819 }, { @@ -165,8 +165,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100000, - "ms_per_run": 0.2424219397590394, - "runs": 581, + "ms_per_run": 0.24910213580247, + "runs": 566, "checksum": 64381668 }, { @@ -175,8 +175,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100000, - "ms_per_run": 3.041258000000003, - "runs": 49, + "ms_per_run": 3.0478165714285654, + "runs": 47, "checksum": 64381668 }, { @@ -185,8 +185,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000000, - "ms_per_run": 2.4348914444444367, - "runs": 63, + "ms_per_run": 2.4940626666666756, + "runs": 59, "checksum": 454838306 }, { @@ -195,7 +195,7 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000000, - "ms_per_run": 30.34673700000002, + "ms_per_run": 30.54691399999996, "runs": 7, "checksum": 454838306 } diff --git a/benchmarks/issue-10089/artifacts.json b/benchmarks/issue-10089/artifacts.json index 5614aa2c2b..7f74f809e8 100644 --- a/benchmarks/issue-10089/artifacts.json +++ b/benchmarks/issue-10089/artifacts.json @@ -52,8 +52,8 @@ }, "after": { "commit": "50e08e91dd6a54d9d9210c43a5d36c86d880d144 plus working-tree change", - "set_executable_sha256": "ebdff93636b9326c526a29defb0235bff0e15548ccd7c40a834621f118aafa3b", - "get_executable_sha256": "4aac90b7e1aee138f907b2724032e4af6a97a1643e3c0933928e8cf33e59cfae", + "set_executable_sha256": "645c83f26dfa2e27e2ca261d5514b620fc8d02f5cc8918e90adcd5df58f56af1", + "get_executable_sha256": "48b3f03f40d2c3fce9039af32b3c191452e5fbe72d6b9baaab155d75ab9dafba", "gap_executable_sha256": "4327c13fe7530f6fed629f405b83eed40db29d43ffe9e06a945b15bbd9125e1d", "perry_sha256": "61b10fe2ceacdcc13ad6f5756eb2a22138e91542aa2c51582ceebc6374b1ee53", "runtime_archive_sha256": "d811de0f23dadd9601c6821a7f81969f5d40ecb0af27001c5c3dce665648cf51", diff --git a/changelog.d/10134-dataview-setter-fast-path.md b/changelog.d/10134-dataview-setter-fast-path.md new file mode 100644 index 0000000000..95338ce3ce --- /dev/null +++ b/changelog.d/10134-dataview-setter-fast-path.md @@ -0,0 +1,16 @@ +### Performance + +- **DataView numeric setters avoid a transient GC handle and view-table probe + when their offset and value are already Numbers.** DataView construction now + caches the stable byte pointer into its canonical backing allocation, while + the existing view registry remains the traced owning edge. Calls that can + coerce user values or write BigInts retain their handle scope and reload the + receiver after callbacks, including a moving collection or detach. + + On the issue's seeded set-and-read workload, serialized release measurements + improve Perry time by 9.0–10.2% across the stable 100–100,000-element range. + The known threshold anomaly limits the one-million-element run to 0.8%, while + its setter/getter ratio is still 1.90x instead of the issue's original 6.68x. + All eight numeric kinds, both byte orders, shared-view coherency, + coercion/error ordering, detach, and forced evacuation are covered by Node + parity tests. From 7563ee0222d1c9f2940ad28a6039bf9f5d51d387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 16:10:13 +0200 Subject: [PATCH 11/36] fix(runtime): preserve DataView caches in structured clones (cherry picked from commit 883bc534d24f62dd8fa478c17caf3a5f7ebcf1fc) --- benchmarks/issue-10089/README.md | 26 +++--- benchmarks/issue-10089/after.json | 72 ++++++++--------- benchmarks/issue-10089/artifacts.json | 12 +-- .../10134-dataview-setter-fast-path.md | 11 ++- crates/perry-runtime/src/builtins/globals.rs | 80 ++++++++++++++++++- ...est_gap_10089_dataview_setter_fast_path.ts | 16 ++++ 6 files changed, 152 insertions(+), 65 deletions(-) diff --git a/benchmarks/issue-10089/README.md b/benchmarks/issue-10089/README.md index 8561a51846..e8dbcf6224 100644 --- a/benchmarks/issue-10089/README.md +++ b/benchmarks/issue-10089/README.md @@ -22,23 +22,21 @@ Perry checksum matched Node at every size. | n | Before Perry setter | After Perry setter | After Node setter | Before ratio | After ratio | After getter ratio | |---:|---:|---:|---:|---:|---:|---:| -| 100 | 0.006030 | 0.005485 | 0.000273 | 22.50x | 20.10x | 11.50x | -| 1,000 | 0.058436 | 0.052530 | 0.002515 | 23.36x | 20.88x | 12.25x | -| 10,000 | 0.581095 | 0.522024 | 0.025047 | 23.51x | 20.84x | 12.27x | -| 100,000 | 5.730139 | 5.185880 | 0.250468 | 23.26x | 20.70x | 12.24x | -| 1,000,000 | 58.591890 | 58.148333 | 2.509861 | 23.70x | 23.17x | 12.25x | - -Across the stable 100–100k range, the Perry setter workload improves by -9.0–10.2% and is 1.70–1.77 times the getter control. The 1M row still improves -by 0.8% but hits the threshold anomaly described in the issue, at 1.90 times -the getter rather than the issue's original 6.68 times. The remaining -difference includes a second accessor per iteration and numeric +| 100 | 0.006030 | 0.005387 | 0.000269 | 22.50x | 20.03x | 11.52x | +| 1,000 | 0.058436 | 0.052222 | 0.002510 | 23.36x | 20.80x | 12.46x | +| 10,000 | 0.581095 | 0.518971 | 0.024820 | 23.51x | 20.91x | 12.35x | +| 100,000 | 5.730139 | 5.192406 | 0.247603 | 23.26x | 20.97x | 12.33x | +| 1,000,000 | 58.591890 | 51.767301 | 2.471560 | 23.70x | 20.95x | 12.31x | + +The Perry setter workload improves by 9.4–11.6% and is 1.72–1.76 times the +getter control, rather than the issue's original 6.68 times at 1M. The +remaining difference includes a second accessor per iteration and numeric wrapping/storage; the getter baseline itself remains out of scope. Raw results are `before.json` and `after.json`. -Least-squares log(time)/log(n) slopes remain near-linear: before setter Perry -0.997, Node 0.992; after setter Perry 1.005, Node 0.993. After getter slopes are -Perry 0.999 and Node 0.993. +Least-squares log(time)/log(n) slopes remain linear: before setter Perry 0.997, +Node 0.992; after setter Perry 0.996, Node 0.992. After getter slopes are Perry +0.999 and Node 0.994. ## Pre-change attribution diff --git a/benchmarks/issue-10089/after.json b/benchmarks/issue-10089/after.json index 07471c7cc6..4b025ed2c4 100644 --- a/benchmarks/issue-10089/after.json +++ b/benchmarks/issue-10089/after.json @@ -5,8 +5,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100, - "ms_per_run": 0.0002728407841431411, - "runs": 513344, + "ms_per_run": 0.00026892069596076023, + "runs": 519811, "checksum": 53205556 }, { @@ -15,8 +15,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100, - "ms_per_run": 0.005485234439266762, - "runs": 24992, + "ms_per_run": 0.0053867756531115986, + "runs": 25268, "checksum": 53205556 }, { @@ -25,8 +25,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000, - "ms_per_run": 0.0025154399446612945, - "runs": 55610, + "ms_per_run": 0.0025101219879519144, + "runs": 55706, "checksum": 509007827 }, { @@ -35,8 +35,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000, - "ms_per_run": 0.05253031758529973, - "runs": 2665, + "ms_per_run": 0.05222245691905451, + "runs": 2663, "checksum": 509007827 }, { @@ -45,8 +45,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 10000, - "ms_per_run": 0.025046856070088715, - "runs": 5597, + "ms_per_run": 0.02482009553349933, + "runs": 5643, "checksum": 6182819 }, { @@ -55,7 +55,7 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 10000, - "ms_per_run": 0.5220241282051301, + "ms_per_run": 0.5189708461538495, "runs": 273, "checksum": 6182819 }, @@ -65,8 +65,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100000, - "ms_per_run": 0.2504683250000099, - "runs": 560, + "ms_per_run": 0.24760332098765048, + "runs": 567, "checksum": 64481668 }, { @@ -75,7 +75,7 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 100000, - "ms_per_run": 5.185880249999997, + "ms_per_run": 5.192406250000033, "runs": 28, "checksum": 64481668 }, @@ -85,8 +85,8 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000000, - "ms_per_run": 2.5098611250000715, - "runs": 56, + "ms_per_run": 2.4715601111110774, + "runs": 62, "checksum": 455838306 }, { @@ -95,7 +95,7 @@ "name": "binary-dataview-set", "category": "binary-node", "n": 1000000, - "ms_per_run": 58.148332999999866, + "ms_per_run": 51.76730099999986, "runs": 7, "checksum": 455838306 }, @@ -105,8 +105,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100, - "ms_per_run": 0.0002696610398015311, - "runs": 498649, + "ms_per_run": 0.000265062859490542, + "runs": 510455, "checksum": 53205456 }, { @@ -115,8 +115,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100, - "ms_per_run": 0.003101427042952572, - "runs": 45281, + "ms_per_run": 0.0030535497709917123, + "runs": 45860, "checksum": 53205456 }, { @@ -125,8 +125,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000, - "ms_per_run": 0.0024868030585604418, - "runs": 51286, + "ms_per_run": 0.0024278540907988313, + "runs": 52452, "checksum": 509006827 }, { @@ -135,8 +135,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000, - "ms_per_run": 0.030473199391173125, - "runs": 4597, + "ms_per_run": 0.03023969788519657, + "runs": 4632, "checksum": 509006827 }, { @@ -145,8 +145,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 10000, - "ms_per_run": 0.024965387780549065, - "runs": 4978, + "ms_per_run": 0.024489498164015678, + "runs": 5264, "checksum": 6172819 }, { @@ -155,8 +155,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 10000, - "ms_per_run": 0.3064169090909098, - "runs": 462, + "ms_per_run": 0.30233589552238305, + "runs": 467, "checksum": 6172819 }, { @@ -165,8 +165,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100000, - "ms_per_run": 0.24910213580247, - "runs": 566, + "ms_per_run": 0.24528795121951136, + "runs": 573, "checksum": 64381668 }, { @@ -175,8 +175,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 100000, - "ms_per_run": 3.0478165714285654, - "runs": 47, + "ms_per_run": 3.0253681428571246, + "runs": 49, "checksum": 64381668 }, { @@ -185,8 +185,8 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000000, - "ms_per_run": 2.4940626666666756, - "runs": 59, + "ms_per_run": 2.4499883333333323, + "runs": 61, "checksum": 454838306 }, { @@ -195,7 +195,7 @@ "name": "binary-dataview-get", "category": "binary-node", "n": 1000000, - "ms_per_run": 30.54691399999996, + "ms_per_run": 30.166053000000034, "runs": 7, "checksum": 454838306 } diff --git a/benchmarks/issue-10089/artifacts.json b/benchmarks/issue-10089/artifacts.json index 7f74f809e8..1d9162ffb2 100644 --- a/benchmarks/issue-10089/artifacts.json +++ b/benchmarks/issue-10089/artifacts.json @@ -52,12 +52,12 @@ }, "after": { "commit": "50e08e91dd6a54d9d9210c43a5d36c86d880d144 plus working-tree change", - "set_executable_sha256": "645c83f26dfa2e27e2ca261d5514b620fc8d02f5cc8918e90adcd5df58f56af1", - "get_executable_sha256": "48b3f03f40d2c3fce9039af32b3c191452e5fbe72d6b9baaab155d75ab9dafba", - "gap_executable_sha256": "4327c13fe7530f6fed629f405b83eed40db29d43ffe9e06a945b15bbd9125e1d", - "perry_sha256": "61b10fe2ceacdcc13ad6f5756eb2a22138e91542aa2c51582ceebc6374b1ee53", - "runtime_archive_sha256": "d811de0f23dadd9601c6821a7f81969f5d40ecb0af27001c5c3dce665648cf51", - "stdlib_archive_sha256": "f4515aa0e61db81a0480e164f910c1ae580eb80699acb9a0fbb725e5f68e89ca" + "set_executable_sha256": "29b33320719fff409c63eb2fee2b7aa229dfe4f3f72cc9f6ed6aaafed0fe7bc2", + "get_executable_sha256": "6dfe429b8c7234237a423179c16a961cb92ee0a6502a3a2cc3df05273cb61491", + "gap_executable_sha256": "a6538dd5cdb69f268d28e26c7d8c19a0c979a3ee5774d4da7ac4fca16e507bb5", + "perry_sha256": "1895d1da6a43549f66f569b633eb8efbdd7001fdd1bf87a0d216f2be7ff4ed4d", + "runtime_archive_sha256": "f2d99f5266c20d3ac1bd91cae23834bac877b9855f770de6fe04c6545b193f6d", + "stdlib_archive_sha256": "265917df8c9e3d28a5f1b1c210db114fea7fb89ec9d253f533a42a291acc21e6" } } } diff --git a/changelog.d/10134-dataview-setter-fast-path.md b/changelog.d/10134-dataview-setter-fast-path.md index 95338ce3ce..d71894b129 100644 --- a/changelog.d/10134-dataview-setter-fast-path.md +++ b/changelog.d/10134-dataview-setter-fast-path.md @@ -8,9 +8,8 @@ receiver after callbacks, including a moving collection or detach. On the issue's seeded set-and-read workload, serialized release measurements - improve Perry time by 9.0–10.2% across the stable 100–100,000-element range. - The known threshold anomaly limits the one-million-element run to 0.8%, while - its setter/getter ratio is still 1.90x instead of the issue's original 6.68x. - All eight numeric kinds, both byte orders, shared-view coherency, - coercion/error ordering, detach, and forced evacuation are covered by Node - parity tests. + improve Perry time by 9.4–11.6% from 100 through 1,000,000 elements. The + setter/getter ratio is 1.72–1.76x instead of the issue's original 6.68x. All + eight numeric kinds, both byte orders, shared-view and structured-clone + coherency, coercion/error ordering, detach, and forced evacuation are covered + by Node parity tests. diff --git a/crates/perry-runtime/src/builtins/globals.rs b/crates/perry-runtime/src/builtins/globals.rs index a25fc16f91..6fc89dac9e 100644 --- a/crates/perry-runtime/src/builtins/globals.rs +++ b/crates/perry-runtime/src/builtins/globals.rs @@ -607,6 +607,33 @@ fn clone_buffer_header(addr: usize, detach_source: bool) -> f64 { let src = addr as *mut crate::buffer::BufferHeader; let src_len = unsafe { (*src).length }; + // A constructor-created DataView has a private cached data pointer rather + // than inline bytes. Clone its visible window into a fresh ArrayBuffer and + // go through the constructor so the clone gets the same representation; + // merely marking an inline buffer as a DataView would make the numeric + // setter interpret its first bytes as that cache pointer. + if crate::buffer::is_data_view(addr) { + let backing = crate::buffer::buffer_alloc(src_len); + unsafe { + (*backing).length = src_len; + if src_len > 0 { + std::ptr::copy_nonoverlapping( + crate::buffer::buffer_data(src), + crate::buffer::buffer_data_mut(backing), + src_len as usize, + ); + } + } + crate::buffer::mark_as_array_buffer(backing as usize); + let backing_value = crate::value::js_nanbox_pointer(backing as i64); + let cloned = crate::buffer::js_data_view_new(backing_value, 0.0, src_len as f64); + if detach_source { + let cloned_addr = pointer_addr(cloned).unwrap_or(0); + record_transfer_clone(addr, cloned_addr); + } + return cloned; + } + let dst = crate::buffer::buffer_alloc(src_len); unsafe { (*dst).length = src_len; @@ -624,9 +651,6 @@ fn clone_buffer_header(addr: usize, detach_source: bool) -> f64 { crate::buffer::mark_as_array_buffer(dst_addr); } else if crate::buffer::is_shared_array_buffer(addr) { crate::buffer::mark_as_shared_array_buffer(dst_addr); - } else if crate::buffer::is_data_view(addr) { - crate::buffer::mark_as_data_view(dst_addr); - crate::buffer::set_buffer_ab_alias(dst_addr, crate::buffer::resolve_buffer_ab_alias(addr)); } else if crate::buffer::is_uint8array_buffer(addr) { crate::buffer::mark_as_uint8array(dst_addr); crate::buffer::set_buffer_ab_alias(dst_addr, crate::buffer::resolve_buffer_ab_alias(addr)); @@ -1342,4 +1366,54 @@ mod structured_clone_tests { } } } + + #[test] + fn structured_clone_data_view_uses_the_cached_registered_representation() { + let backing = crate::buffer::js_array_buffer_new(8); + let backing_value = crate::value::js_nanbox_pointer(backing as i64); + let source = crate::buffer::js_data_view_new(backing_value, 0.0, 8.0); + crate::buffer::js_data_view_set( + source, + 0.0, + 0x0102_0304u32 as f64, + crate::buffer::DataViewKind::Uint32, + false, + ); + + let cloned = js_structured_clone(source); + let cloned_addr = pointer_addr(cloned).expect("DataView clone must be a pointer"); + assert!(crate::buffer::is_data_view(cloned_addr)); + assert_ne!( + crate::buffer::buffer_backing_array_buffer(cloned_addr), + backing as usize, + "the clone must own an independent ArrayBuffer" + ); + + crate::buffer::js_data_view_set( + cloned, + 4.0, + 0x0506_0708u32 as f64, + crate::buffer::DataViewKind::Uint32, + false, + ); + assert_eq!( + crate::buffer::js_data_view_get( + cloned, + 4.0, + crate::buffer::DataViewKind::Uint32, + false, + ), + 0x0506_0708u32 as f64 + ); + assert_eq!( + crate::buffer::js_data_view_get( + source, + 4.0, + crate::buffer::DataViewKind::Uint32, + false, + ), + 0.0, + "writing the clone must not change its source" + ); + } } diff --git a/test-files/test_gap_10089_dataview_setter_fast_path.ts b/test-files/test_gap_10089_dataview_setter_fast_path.ts index b5f92989f2..648a0e502c 100644 --- a/test-files/test_gap_10089_dataview_setter_fast_path.ts +++ b/test-files/test_gap_10089_dataview_setter_fast_path.ts @@ -131,3 +131,19 @@ console.log("numeric little-endian", numericRoundTrips(true)); view.getBigUint64(8, true).toString(16), ); } + +{ + const source = new DataView(new ArrayBuffer(8)); + source.setUint32(0, 0x01020304, false); + const cloned = structuredClone(source); + cloned.setUint32(4, 0x05060708, false); + console.log( + "structured clone", + cloned.byteOffset, + cloned.byteLength, + cloned.getUint32(0, false).toString(16), + cloned.getUint32(4, false).toString(16), + source.getUint32(4, false), + cloned.buffer === source.buffer, + ); +} From 4183cb13f813c89a5ececbe93a6a9e53fea4e391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 14:05:06 +0200 Subject: [PATCH 12/36] fix(runtime): bound splice and unshift layout work (cherry picked from commit ccf2e64dd81b866ae0ad1f35883c556f07f301ff) --- .../array-splice-unshift-10087/.gitignore | 4 + .../array-splice-middle-insert.ts | 90 ++++++ .../array-splice-middle-remove.ts | 93 ++++++ .../array-unshift-build.ts | 90 ++++++ .../array-splice-unshift-10087/before.json | 290 ++++++++++++++++++ benchmarks/array-splice-unshift-10087/run.py | 135 ++++++++ .../10087-array-splice-unshift-layout.md | 6 + .../src/array/dense_move_tests.rs | 90 ++++++ .../src/array/element_shape_matrix_tests.rs | 4 +- .../src/array/element_shape_tests.rs | 4 +- .../src/array/header_gc_slots.rs | 107 +++++++ crates/perry-runtime/src/array/mod.rs | 19 +- crates/perry-runtime/src/array/push_pop.rs | 33 +- .../perry-runtime/src/array/splice_slice.rs | 22 +- crates/perry-runtime/src/gc/layout.rs | 2 +- crates/perry-runtime/src/gc/tests/copying.rs | 1 + .../src/gc/tests/copying/splice_unshift.rs | 97 ++++++ scripts/gc_store_site_inventory.py | 3 + 18 files changed, 1068 insertions(+), 22 deletions(-) create mode 100644 benchmarks/array-splice-unshift-10087/.gitignore create mode 100644 benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts create mode 100644 benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts create mode 100644 benchmarks/array-splice-unshift-10087/array-unshift-build.ts create mode 100644 benchmarks/array-splice-unshift-10087/before.json create mode 100644 benchmarks/array-splice-unshift-10087/run.py create mode 100644 changelog.d/10087-array-splice-unshift-layout.md create mode 100644 crates/perry-runtime/src/array/dense_move_tests.rs create mode 100644 crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs diff --git a/benchmarks/array-splice-unshift-10087/.gitignore b/benchmarks/array-splice-unshift-10087/.gitignore new file mode 100644 index 0000000000..ea265de2ed --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/.gitignore @@ -0,0 +1,4 @@ +array-splice-middle-remove +array-splice-middle-insert +array-unshift-build +*.exe diff --git a/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts b/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts new file mode 100644 index 0000000000..df40d5c9fa --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts @@ -0,0 +1,90 @@ +// @runtime {"name": "array-splice-middle-insert", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/splice_slice.rs", "function": "js_array_splice"}], "hypothesis": "Every middle insertion creates a deleted-elements array, memmoves the tail and rebuilds the live array layout.", "notes": "n single-element insertions build an initially empty array; the input values are prepared before timing.", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): number[] { return numbers(n); } + +function run(input: number[]): number { + const a: number[] = []; + for (let i = 0; i < input.length; i++) a.splice(Math.floor(a.length / 2), 0, input[i]); + return hashArray(a); +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "array-splice-middle-insert", category: "arrays", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts b/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts new file mode 100644 index 0000000000..b9792b0921 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts @@ -0,0 +1,93 @@ +// @runtime {"name": "array-splice-middle-remove", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/splice_slice.rs", "function": "js_array_splice"}], "hypothesis": "Every middle removal allocates the return array, memmoves the surviving tail and rebuilds the live array layout.", "notes": "n single-element middle removals drain a fresh input array.", "asynchronous": false, "output_stderr": false, "fresh_input": true} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): number[] { return numbers(n); } + +function run(a: number[]): number { + let h = 0; + while (a.length) { + const removed = a.splice(Math.floor(a.length / 2), 1); + h = (h * 31 + removed[0]) % 1000000007; + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = setup(n); + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = setup(n); + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "array-splice-middle-remove", category: "arrays", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/array-unshift-build.ts b/benchmarks/array-splice-unshift-10087/array-unshift-build.ts new file mode 100644 index 0000000000..52276ab7e7 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/array-unshift-build.ts @@ -0,0 +1,90 @@ +// @runtime {"name": "array-unshift-build", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/push_pop.rs", "function": "js_array_unshift_f64"}], "hypothesis": "Each unshift memmoves the whole live prefix and rebuilds its array layout, making repeated front insertion quadratic.", "notes": "", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): number[] { return numbers(n); } + +function run(input: number[]): number { + const a: number[] = []; + for (let i = 0; i < input.length; i++) a.unshift(input[i]); + return hashArray(a); +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "array-unshift-build", category: "arrays", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/before.json b/benchmarks/array-splice-unshift-10087/before.json new file mode 100644 index 0000000000..8ed962075d --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/before.json @@ -0,0 +1,290 @@ +{ + "revision": "50e08e91dd6a54d9d9210c43a5d36c86d880d144", + "node": "v26.5.1", + "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", + "cpu": "x86_64", + "artifact_sha256": { + "perry": "69cdc2258e9e5ae66ac42f1a29e54bb3f44a136e1c3e5632c65b86215a172ed1", + "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", + "runtime": "c9b6cd15f8ded630ef23e2faf529e94af927df2fd5194f5c51e8ca1d220e8cbc", + "stdlib": "b49927e243be3298d146afd57ce86287be1840829e67940bebc4b1bd928a7be4", + "array-splice-middle-remove.ts": "0ce71b76ad2801128883bd523af918e27ec4340e73d745582676dc2b77871b26", + "array-splice-middle-insert.ts": "5a17c728ca431cb2aca89d77315b394e2fb02f077c48cb64a8930d10483dd93e", + "array-unshift-build.ts": "8969ee530d12ec1aaa3d2714d86d7d13fa7cfd4d89aa98bf7d684962e4ed152f" + }, + "workloads": { + "array-splice-middle-remove": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.002542719171116567, + "runs": 55133, + "checksum": 658638221, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.011850479857819961, + "runs": 11882, + "checksum": 658638221, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.03876850000000078, + "runs": 3587, + "checksum": 109957063, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.5791328000000021, + "runs": 245, + "checksum": 109957063, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.7828065833333302, + "runs": 84, + "checksum": 733399264, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 51.293488000000025, + "runs": 7, + "checksum": 733399264, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 249.9612900000002, + "runs": 7, + "checksum": 452640523, + "status": "OK" + }, + "perry": { + "status": "TIMEOUT" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000 + ], + "common_slopes": { + "node": 1.422902915830406, + "perry": 1.818163148001364 + } + }, + "array-splice-middle-insert": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.003527362257494725, + "runs": 39499, + "checksum": 619454386, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.012534371553884009, + "runs": 11235, + "checksum": 619454386, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.04604668275862028, + "runs": 3046, + "checksum": 604367096, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.5808904857142823, + "runs": 245, + "checksum": 604367096, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.754180750000008, + "runs": 84, + "checksum": 37547811, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 51.82242600000001, + "runs": 7, + "checksum": 37547811, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 214.31949000000031, + "runs": 7, + "checksum": 275047240, + "status": "OK" + }, + "perry": { + "status": "TIMEOUT" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000 + ], + "common_slopes": { + "node": 1.348312138582441, + "perry": 1.8082075879008503 + } + }, + "array-unshift-build": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.0033888458149777907, + "runs": 41225, + "checksum": 922626605, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.010258765128205217, + "runs": 13533, + "checksum": 922626605, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.057028461538462895, + "runs": 2458, + "checksum": 414934349, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.5598590833333369, + "runs": 252, + "checksum": 414934349, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 3.357314666666658, + "runs": 42, + "checksum": 183209813, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 51.20603699999998, + "runs": 7, + "checksum": 183209813, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100000, + "ms_per_run": 328.3525890000001, + "runs": 7, + "checksum": 622348785, + "status": "OK" + }, + "perry": { + "status": "TIMEOUT" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000 + ], + "common_slopes": { + "node": 1.4979701189328156, + "perry": 1.8491130394091182 + } + } + } +} diff --git a/benchmarks/array-splice-unshift-10087/run.py b/benchmarks/array-splice-unshift-10087/run.py new file mode 100644 index 0000000000..d8a880e11b --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/run.py @@ -0,0 +1,135 @@ +"""Sequential checksum-gated issue #10087 benchmark (60 s per process).""" +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import subprocess + + +WORKLOADS = ( + "array-splice-middle-remove", + "array-splice-middle-insert", + "array-unshift-build", +) + + +def artifact_hashes(perry, node, sources): + windows = os.name == "nt" + paths = {"perry": perry, "node": Path(node)} + for name in ("runtime", "stdlib"): + paths[name] = perry.parent / (f"perry_{name}.lib" if windows else f"libperry_{name}.a") + paths.update({source.name: source for source in sources}) + return {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items()} + + +def slope(rows): + if len(rows) < 2: + return None + x = [math.log(n) for n, _ in rows] + y = [math.log(t) for _, t in rows] + mx, my = sum(x) / len(x), sum(y) / len(y) + return sum((a - mx) * (b - my) for a, b in zip(x, y)) / sum( + (a - mx) ** 2 for a in x + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--perry", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--node", default="node") + args = parser.parse_args() + args.node = subprocess.check_output([args.node, "-p", "process.execPath"], text=True).strip() + root = Path(__file__).resolve().parent + sources = [root / f"{name}.ts" for name in WORKLOADS] + env = dict( + os.environ, + PERRY_RUNTIME_DIR=str(args.perry.resolve().parent), + TZ="UTC", + LC_ALL="en_US.UTF-8", + ) + result = { + "revision": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "node": subprocess.check_output([args.node, "--version"], text=True).strip(), + "host": platform.platform(), + "cpu": platform.processor(), + "artifact_sha256": artifact_hashes(args.perry.resolve(), args.node, sources), + "workloads": {}, + } + for name in WORKLOADS: + source = root / f"{name}.ts" + binary = args.output.resolve().parent / (name + (".exe" if os.name == "nt" else "")) + subprocess.run( + [ + str(args.perry.resolve()), + "compile", + str(source), + "--no-auto-optimize", + "--no-cache", + "-o", + str(binary), + ], + env=env, + check=True, + ) + rows, stopped = [], set() + for n in (100, 1000, 10000, 100000): + pair = {} + for engine, command in ( + ("node", [args.node, str(source)]), + ("perry", [str(binary)]), + ): + if engine in stopped: + row = {"status": "SKIPPED"} + else: + try: + process = subprocess.run( + command + [str(n)], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + stopped.add(engine) + row = {"status": "TIMEOUT"} + else: + if process.returncode: + raise RuntimeError( + f"{engine} {name} {n}: {process.returncode}\n" + f"{process.stdout}\n{process.stderr}" + ) + row = dict(json.loads(process.stdout), status="OK") + pair[engine] = row + print(name, n, engine, json.dumps(row), flush=True) + if all(pair[engine]["status"] == "OK" for engine in ("node", "perry")): + assert pair["node"]["checksum"] == pair["perry"]["checksum"], pair + rows.append({"n": n, **pair}) + result["workloads"][name] = {"rows": rows} + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + common = [row for row in rows if all(row[e]["status"] == "OK" for e in ("node", "perry"))] + result["workloads"][name].update( + common_sizes=[row["n"] for row in common], + common_slopes={ + engine: slope([(row["n"], row[engine]["ms_per_run"]) for row in common]) + for engine in ("node", "perry") + }, + ) + acceptance = [row for row in common if 1000 <= row["n"] <= 100000] + acceptance_slopes = { + engine: slope([(row["n"], row[engine]["ms_per_run"]) for row in acceptance]) + for engine in ("node", "perry") + } + result["workloads"][name].update( + acceptance_sizes=[row["n"] for row in acceptance], + acceptance_slopes=acceptance_slopes, + acceptance_slope_delta=acceptance_slopes["perry"] - acceptance_slopes["node"], + ) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/changelog.d/10087-array-splice-unshift-layout.md b/changelog.d/10087-array-splice-unshift-layout.md new file mode 100644 index 0000000000..381d2ffbd3 --- /dev/null +++ b/changelog.d/10087-array-splice-unshift-layout.md @@ -0,0 +1,6 @@ +Make dense `Array.splice()` and `Array.unshift()` update GC element metadata +from only the inserted slots instead of rebuilding it from every live element. +Pointer-free and all-pointer layouts remain exact, mixed layouts fall back to +conservative scanning, and old-array dirty-page coverage follows moved +survivors. Repeated middle splice and front insertion no longer time out at +100,000 operations. diff --git a/crates/perry-runtime/src/array/dense_move_tests.rs b/crates/perry-runtime/src/array/dense_move_tests.rs new file mode 100644 index 0000000000..40a028d138 --- /dev/null +++ b/crates/perry-runtime/src/array/dense_move_tests.rs @@ -0,0 +1,90 @@ +use super::header::array_numeric_layout; +use super::header_gc_slots::{ + test_dense_move_layout_classified_slots, test_reset_dense_move_layout_classified_slots, +}; +use super::*; + +#[test] +fn repeated_dense_unshift_classifies_only_the_inserted_slots() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + const N: usize = 512; + let arr = js_array_alloc(N as u32); + + test_reset_dense_move_layout_classified_slots(); + for value in 0..N { + assert_eq!(js_array_unshift_f64(arr, value as f64), arr); + } + + assert_eq!(test_dense_move_layout_classified_slots(), N); + assert_eq!(js_array_length(arr), N as u32); + for index in 0..N { + assert_eq!(js_array_get_f64(arr, index as u32), (N - index - 1) as f64); + } +} + +#[test] +fn repeated_dense_splice_layout_work_is_linear_in_operations() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + const N: usize = 256; + + let inserted = js_array_alloc(N as u32); + test_reset_dense_move_layout_classified_slots(); + for value in 0..N { + let item = [value as f64]; + let mut out = inserted; + let deleted = js_array_splice(inserted, (value / 2) as i32, 0, item.as_ptr(), 1, &mut out); + assert_eq!(out, inserted); + assert_eq!(js_array_length(deleted), 0); + } + assert_eq!(test_dense_move_layout_classified_slots(), N); + + let removed = js_array_alloc(N as u32); + for value in 0..N { + assert_eq!(js_array_push_f64(removed, value as f64), removed); + } + test_reset_dense_move_layout_classified_slots(); + for _ in 0..N { + let mut out = removed; + let deleted = js_array_splice( + removed, + (js_array_length(removed) / 2) as i32, + 1, + std::ptr::null(), + 0, + &mut out, + ); + assert_eq!(out, removed); + assert_eq!(js_array_length(deleted), 1); + } + assert_eq!(test_dense_move_layout_classified_slots(), N); + assert_eq!(js_array_length(removed), 0); +} + +#[test] +fn dense_moves_preserve_the_existing_numeric_layout_without_a_rebuild() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let arr = js_array_alloc(16); + for value in [1.0, 2.0, 3.0, 4.0] { + assert_eq!(js_array_push_f64(arr, value), arr); + } + assert_eq!( + unsafe { array_numeric_layout(arr) }, + Some(NumericArrayLayout::RawF64) + ); + + let arr = js_array_unshift_f64(arr, f64::from_bits(crate::value::JSValue::int32(0).bits())); + assert_eq!( + unsafe { array_numeric_layout(arr) }, + Some(NumericArrayLayout::RawF64) + ); + assert_eq!(js_array_get_f64(arr, 0), 0.0); + + let item = [f64::from_bits(crate::value::JSValue::int32(9).bits())]; + let mut out = arr; + js_array_splice(arr, 2, 1, item.as_ptr(), 1, &mut out); + assert_eq!( + unsafe { array_numeric_layout(out) }, + Some(NumericArrayLayout::RawF64) + ); + assert_eq!(js_array_get_f64(out, 2), 9.0); +} diff --git a/crates/perry-runtime/src/array/element_shape_matrix_tests.rs b/crates/perry-runtime/src/array/element_shape_matrix_tests.rs index e40d1a989b..dc862cae3e 100644 --- a/crates/perry-runtime/src/array/element_shape_matrix_tests.rs +++ b/crates/perry-runtime/src/array/element_shape_matrix_tests.rs @@ -291,8 +291,8 @@ fn matrix_fill_range_revokes_leaving_a_genuinely_mixed_array() { /// The soundness-critical case: `splice` can replace elements **without /// changing `length`**, so the structural `verified_len` check cannot catch it. -/// Only splice's own `rebuild_array_layout` can. If that call is ever dropped, -/// this is the test that goes red — and nothing else would. +/// Only splice's own dense-move layout finisher can. If that call ever stops +/// revoking the proof, this is the test that goes red — and nothing else would. #[test] fn matrix_splice_equal_length_replacement_revokes() { let _serialized = test_serialize(); diff --git a/crates/perry-runtime/src/array/element_shape_tests.rs b/crates/perry-runtime/src/array/element_shape_tests.rs index 36adee7595..2fe5bc67e6 100644 --- a/crates/perry-runtime/src/array/element_shape_tests.rs +++ b/crates/perry-runtime/src/array/element_shape_tests.rs @@ -345,8 +345,8 @@ fn a_length_change_behind_the_runtimes_back_fails_the_proof_closed() { #[test] fn a_bulk_mutator_rebuild_clears_the_invariant() { let _serialized = test_serialize(); - // `shift`/`unshift`/`splice`/`fill`/`copyWithin`/`reverse`/`sort` all - // mutate slots with bare writes and then land in `rebuild_array_layout`. + // Bulk mutators that land in `rebuild_array_layout` must revoke the proof; + // splice/unshift have the same obligation through their dense-move helper. let arr = built_from_pushes(CLASS_A, 4); assert!(proof(arr).is_some()); unsafe { crate::array::header::rebuild_array_layout(arr) }; diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index d67434c628..9d4d7cd433 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -6,6 +6,29 @@ use super::header::*; use super::ArrayHeader; +#[cfg(test)] +thread_local! { + static DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn test_reset_dense_move_layout_classified_slots() { + DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn test_dense_move_layout_classified_slots() -> usize { + DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS.with(std::cell::Cell::get) +} + +#[inline] +fn note_layout_classified_slots(count: usize) { + #[cfg(test)] + DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS.with(|total| total.set(total.get() + count)); + #[cfg(not(test))] + let _ = count; +} + pub(crate) unsafe fn gc_element_slot_range( arr: *mut ArrayHeader, ) -> Option { @@ -187,6 +210,7 @@ pub(crate) unsafe fn rebuild_array_layout(arr: *mut ArrayHeader) { crate::gc::layout_mark_unknown(arr as *mut u8); return; } + note_layout_classified_slots(length); let was_all_pointer = super::header::array_object_flags_resolved(arr) & (crate::gc::GC_LAYOUT_STATE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS) == (crate::gc::GC_LAYOUT_SIDE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS); @@ -259,6 +283,89 @@ pub(crate) unsafe fn rebuild_array_layout(arr: *mut ArrayHeader) { } } +/// Settle GC metadata after a dense in-place element move without rebuilding +/// it from every live slot. +/// +/// `moved_src..moved_src + moved_count` was copied verbatim to `moved_dst` and +/// `inserted_start..inserted_start + inserted_count` contains the newly-written +/// values. A pointer-free layout remains exact when the inserted values are +/// pointer-free, and the header-only all-pointer proof remains exact when they +/// are all pointers. An index-specific mixed mask no longer names the moved +/// slots, so it is dropped to UNKNOWN in O(1). Element-shape evidence is always +/// revoked: inserted values can change KIND even when the array length does +/// not change (#7480). +/// +/// Survivor references are not new edges, but an old array's dirty-page +/// coverage follows their byte move. Translate that coverage instead of +/// replaying a write barrier for every survivor. A live incremental mark is +/// the rare case where the translation helper declines; the value-derived +/// replay then preserves its insertion-shading contract. +/// +/// # Safety +/// +/// `arr` is a live, forwarding-resolved ordinary Array. All ranges belong to +/// its inline allocation and no safepoint may occur between the move and this +/// call. The caller has already published the new logical length. +#[inline] +pub(crate) unsafe fn finish_array_dense_move_layout( + arr: *mut ArrayHeader, + moved_src: *const u64, + moved_dst: *mut u64, + moved_count: usize, + inserted_start: *mut u64, + inserted_count: usize, +) { + if arr.is_null() { + return; + } + + super::element_shape::clear_element_shape(arr); + let flags = super::header::array_object_flags_resolved(arr); + let layout = flags & (crate::gc::GC_LAYOUT_STATE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS); + let pointer_free = layout == crate::gc::GC_LAYOUT_POINTER_FREE; + let all_pointer = + layout == (crate::gc::GC_LAYOUT_SIDE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS); + + if moved_count != 0 && moved_src != moved_dst.cast_const() && !pointer_free { + let copied_bytes = moved_count * std::mem::size_of::(); + if !crate::gc::relocate_copied_old_object_dirty_pages( + arr as usize, + moved_src as usize, + moved_dst as usize, + copied_bytes, + ) { + crate::gc::replay_old_parent_slot_range_barriers(arr as usize, moved_dst, moved_count); + } + } + + note_layout_classified_slots(inserted_count); + let mut inserted_are_pointer_free = true; + let mut inserted_are_all_pointer = true; + let mut inserted_are_all_numeric = true; + for index in 0..inserted_count { + let slot = inserted_start.add(index); + let bits = *slot; + let pointer = crate::gc::layout_pointer_bearing_bits(bits); + inserted_are_pointer_free &= !pointer; + inserted_are_all_pointer &= pointer; + inserted_are_all_numeric &= value_bits_to_number(bits).is_some(); + crate::gc::runtime_write_barrier_slot(arr as usize, slot as usize, bits); + } + + if !inserted_are_all_numeric { + clear_array_numeric_layout(arr); + } + if (pointer_free && inserted_are_pointer_free) + || (all_pointer && inserted_are_all_pointer) + || (moved_count == 0 && inserted_count == 0) + { + return; + } + if flags & crate::gc::GC_LAYOUT_STATE_MASK != 0 { + crate::gc::layout_mark_unknown(arr.cast()); + } +} + #[inline] pub(crate) unsafe fn rebuild_array_layout_exact(arr: *mut ArrayHeader) { if arr.is_null() { diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index efe0dc67d7..e00ce97b83 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -54,6 +54,8 @@ mod callback_rooting_tests; #[cfg(test)] mod collection_tag_tests; #[cfg(test)] +mod dense_move_tests; +#[cfg(test)] mod forwarding_tests; #[cfg(test)] mod push_pop_tests; @@ -267,14 +269,15 @@ pub(crate) use self::header::{ array_named_property_set, array_numeric_raw_f64_get, array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags, array_object_flags_from_tag, array_object_flags_resolved, array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, - buffer_receiver_as_uint8_typed_array, clean_arr_ptr, clean_arr_ptr_mut, - clear_array_numeric_layout, clear_array_numeric_layout_ptr, gc_element_slot_range, - mark_array_layout_unknown, mark_array_raw_f64_holes_fresh, normalize_array_receiver, - note_array_slot, note_array_slot_layout_only, note_array_slot_resolved_flags, - rebuild_array_layout, rebuild_array_layout_exact, refresh_array_numeric_layout, - replay_array_growth_write_barriers, set_array_numeric_layout, store_array_slot, - store_array_slot_resolved, transfer_array_named_property_owner, transfer_array_numeric_layout, - typed_array_receiver, value_bits_to_number, NumericArrayLayout, MIN_ARRAY_CAPACITY, + buffer_receiver_as_uint8_typed_array, canonicalize_array_numeric_store_value_from_flags, + clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, + finish_array_dense_move_layout, gc_element_slot_range, mark_array_layout_unknown, + mark_array_raw_f64_holes_fresh, normalize_array_receiver, note_array_slot, + note_array_slot_layout_only, note_array_slot_resolved_flags, rebuild_array_layout, + rebuild_array_layout_exact, refresh_array_numeric_layout, replay_array_growth_write_barriers, + set_array_numeric_layout, store_array_slot, store_array_slot_resolved, + transfer_array_named_property_owner, transfer_array_numeric_layout, typed_array_receiver, + value_bits_to_number, NumericArrayLayout, MIN_ARRAY_CAPACITY, }; // Sole caller is the regex-engine-gated `regex::exec_array`, so the helper and diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index f5bb39c5bf..cf970eb277 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -1676,17 +1676,27 @@ pub extern "C" fn js_array_unshift_f64(arr: *mut ArrayHeader, value: f64) -> *mu } else { arr }; - let value = value_handle.get_nanbox_f64(); + let flags = array_object_flags_resolved(arr); + let value = + canonicalize_array_numeric_store_value_from_flags(flags, value_handle.get_nanbox_f64()); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; // Shift all elements up - // GC_STORE_AUDIT(BARRIERED): unshift memmove and new slot are followed by layout/barrier rebuild. + // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates + // survivor dirty pages and barriers the inserted slot below. ptr::copy(elements_ptr, elements_ptr.add(1), length as usize); // Write new element at beginning ptr::write(elements_ptr, value); (*arr).length = length + 1; - rebuild_array_layout(arr); + finish_array_dense_move_layout( + arr, + elements_ptr.cast(), + elements_ptr.add(1).cast(), + length as usize, + elements_ptr.cast(), + 1, + ); arr } } @@ -1758,20 +1768,31 @@ pub extern "C" fn js_array_unshift_variadic( } else { arr }; + let flags = array_object_flags_resolved(arr); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; // Shift existing elements up by `n`. - // GC_STORE_AUDIT(BARRIERED): memmove + new slots followed by layout/barrier rebuild. + // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates + // survivor dirty pages and barriers the inserted slots below. ptr::copy(elements_ptr, elements_ptr.add(n), length as usize); // Write items in source order at the front. #5552: demote each // uniquely-owned string before it aliases its slot (no-op for SSO / // non-string). for (i, v) in item_vec.into_iter().enumerate() { crate::string::js_string_addref_if_heap_string(v); - // GC_STORE_AUDIT(BARRIERED): inserted slots are followed by the layout/barrier rebuild below. + let v = canonicalize_array_numeric_store_value_from_flags(flags, v); + // GC_STORE_AUDIT(BARRIERED): inserted slots are covered by the + // dense-move finisher below. ptr::write(elements_ptr.add(i), v); } (*arr).length = length + n as u32; - rebuild_array_layout(arr); + finish_array_dense_move_layout( + arr, + elements_ptr.cast(), + elements_ptr.add(n).cast(), + length as usize, + elements_ptr.cast(), + n, + ); arr } } diff --git a/crates/perry-runtime/src/array/splice_slice.rs b/crates/perry-runtime/src/array/splice_slice.rs index d2792708fa..4b6f680130 100644 --- a/crates/perry-runtime/src/array/splice_slice.rs +++ b/crates/perry-runtime/src/array/splice_slice.rs @@ -132,6 +132,7 @@ pub extern "C" fn js_array_splice( } else { arr }; + let flags = array_object_flags_resolved(arr); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; // Shift elements after the splice point @@ -142,7 +143,8 @@ pub extern "C" fn js_array_splice( // Need to shift the tail let src = elements_ptr.add(tail_start as usize); let dst = elements_ptr.add((start_idx + items_count) as usize); - // GC_STORE_AUDIT(BARRIERED): splice tail memmove is followed by layout/barrier rebuild. + // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates + // survivor dirty pages below. ptr::copy(src, dst, tail_len as usize); } @@ -155,7 +157,9 @@ pub extern "C" fn js_array_splice( // place. No-op for SSO / non-string. (This insert path doesn't // funnel through `note_array_slot`.) crate::string::js_string_addref_if_heap_string(item); - // GC_STORE_AUDIT(BARRIERED): splice inserted item writes are followed by layout/barrier rebuild. + let item = canonicalize_array_numeric_store_value_from_flags(flags, item); + // GC_STORE_AUDIT(BARRIERED): inserted items are covered by the + // dense-move finisher below. ptr::write(elements_ptr.add(start_idx as usize + i), item); } } @@ -164,7 +168,19 @@ pub extern "C" fn js_array_splice( // non-writable `length` (test262 splice/S15.4.4.12_A6.1_T2/T3). super::push_pop::guard_writable_length(arr); (*arr).length = new_len; - rebuild_array_layout(arr); + let moved_count = if items_count != actual_delete { + tail_len as usize + } else { + 0 + }; + finish_array_dense_move_layout( + arr, + elements_ptr.add(tail_start as usize).cast(), + elements_ptr.add((start_idx + items_count) as usize).cast(), + moved_count, + elements_ptr.add(start_idx as usize).cast(), + items_count as usize, + ); // Return modified array via out param *out_arr = arr; diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 2882f4f75e..2fb98f95da 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -445,7 +445,7 @@ pub(super) fn strip_nanbox_user_ptr(bits: u64) -> usize { } #[inline] -pub(in crate::gc) fn layout_pointer_bearing_bits(bits: u64) -> bool { +pub(crate) fn layout_pointer_bearing_bits(bits: u64) -> bool { let tag = bits & TAG_MASK; if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { return bits & POINTER_MASK != 0; diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index dbbab1bb9e..8f7317b2d4 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -7,6 +7,7 @@ mod pointer_publish_7154; mod promise_side_tables; mod promoted_remembered_7803; mod shift_queue; +mod splice_unshift; mod survival_and_malloc; mod verify_malloc_borrow; mod verify_parent_context; diff --git a/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs b/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs new file mode 100644 index 0000000000..43ffa1e85c --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs @@ -0,0 +1,97 @@ +//! Moving-collector witnesses for splice/unshift dense slot moves (#10087). +use super::*; +use crate::array::{self, ArrayHeader}; + +#[test] +fn promoted_array_keeps_new_unshift_and_splice_children_through_minor_and_full_gc() { + let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); + let mut arr = array::js_array_alloc(16); + for value in [1.0, 2.0, 3.0, 4.0] { + arr = array::js_array_push_f64(arr, value); + } + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + for _ in 0..crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX { + let _ = gc_collect_minor(); + arr = (js_shadow_slot_get(0) & POINTER_MASK) as *mut ArrayHeader; + } + assert!( + crate::arena::pointer_in_old_gen(arr as usize), + "the fixture array must actually be promoted before its stores" + ); + + let first = young_leaf(); + arr = array::js_array_unshift_f64(arr, f64::from_bits(ptr_bits(first))); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + let second = young_leaf(); + let items = [f64::from_bits(ptr_bits(second))]; + let mut out = arr; + array::js_array_splice(arr, 3, 0, items.as_ptr(), 1, &mut out); + arr = out; + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let first_after = (array::js_array_get_f64(arr, 0).to_bits() & POINTER_MASK) as usize; + let second_after = (array::js_array_get_f64(arr, 3).to_bits() & POINTER_MASK) as usize; + assert_ne!(first_after, first); + assert_ne!(second_after, second); + assert!(crate::arena::pointer_in_nursery(first_after)); + assert!(crate::arena::pointer_in_nursery(second_after)); + + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Direct)); + for child in [ + array::js_array_get_f64(arr, 0).to_bits(), + array::js_array_get_f64(arr, 3).to_bits(), + ] { + let child = (child & POINTER_MASK) as *const u8; + unsafe { + assert_ne!((*header_from_user_ptr(child)).size, 0); + } + } + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} + +#[test] +fn old_array_unshift_translates_a_young_edge_across_a_page_boundary() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let arr = array::js_array_alloc(OLD_BORN_ELEMENTS); + assert!(crate::arena::pointer_in_old_gen(arr as usize)); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + let slots = unsafe { array::array_elements_ptr(arr) }; + let source_index = (0..OLD_BORN_ELEMENTS as usize - 1) + .find(|&index| { + crate::arena::generation_page_for_addr(unsafe { slots.add(index) } as usize) + != crate::arena::generation_page_for_addr(unsafe { slots.add(index + 1) } as usize) + }) + .expect("old-born array must span an element page boundary"); + for index in 0..source_index { + assert_eq!(array::js_array_push_f64(arr, index as f64), arr); + } + let child = young_leaf(); + assert_eq!( + array::js_array_push_f64(arr, f64::from_bits(ptr_bits(child))), + arr + ); + assert_eq!(array::js_array_length(arr) as usize, source_index + 1); + + assert_eq!(array::js_array_unshift_f64(arr, -1.0), arr); + let destination = unsafe { slots.add(source_index + 1) }; + assert_ne!( + crate::arena::generation_page_for_addr(unsafe { destination.sub(1) } as usize), + crate::arena::generation_page_for_addr(destination as usize), + "the moved child must cross into a different remembered-set page" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let child_after = + (array::js_array_get_f64(arr, (source_index + 1) as u32).to_bits() & POINTER_MASK) as usize; + assert_ne!(child_after, child); + assert!(crate::arena::pointer_in_nursery(child_after)); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} diff --git a/scripts/gc_store_site_inventory.py b/scripts/gc_store_site_inventory.py index 17071c5638..a61fe6a6e3 100644 --- a/scripts/gc_store_site_inventory.py +++ b/scripts/gc_store_site_inventory.py @@ -673,6 +673,9 @@ def scan_file(path: Path) -> list[Finding]: "replay_array_growth_write_barriers": ( "array/header.rs: replays the copied prefix's barriers after js_array_grow" ), + "finish_array_dense_move_layout": ( + "array/header_gc_slots.rs: translates moved dirty pages and barriers inserted slots" + ), "store_object_field_slot": "object/mod.rs: object field store via runtime_store", "store_object_field_slot_layout_deferred": ( "object/mod.rs: JSON-parser field store; layout settled at finalize (#7630)" From 48568771f1017d7256f5469ed6f7b12680080afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 14:09:24 +0200 Subject: [PATCH 13/36] docs: record splice and unshift scaling evidence (cherry picked from commit 67ea3ed00f019a479347d24493ef3427d3a82ce4) --- .../array-splice-unshift-10087/README.md | 77 ++++ .../array-splice-unshift-10087/after.json | 341 ++++++++++++++++++ ...d => 10126-array-splice-unshift-layout.md} | 0 3 files changed, 418 insertions(+) create mode 100644 benchmarks/array-splice-unshift-10087/README.md create mode 100644 benchmarks/array-splice-unshift-10087/after.json rename changelog.d/{10087-array-splice-unshift-layout.md => 10126-array-splice-unshift-layout.md} (100%) diff --git a/benchmarks/array-splice-unshift-10087/README.md b/benchmarks/array-splice-unshift-10087/README.md new file mode 100644 index 0000000000..9ae56f65eb --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/README.md @@ -0,0 +1,77 @@ +# Array splice/unshift layout evidence (#10087) + +This directory preserves the three standalone issue reproducers and the raw +before/after results. Both sweeps used the unchanged TypeScript sources, +Node v26.5.1, matching release-built Perry artifacts, a 60-second timeout per +process, and a quiet Linux x86_64 host. The JSON artifacts record the exact +source, compiler, runtime, stdlib, and Node hashes. + +## Result + +Perry milliseconds per workload invocation: + +| workload | n | main `50e08e91dd` | candidate `ccf2e64dd8` | +| --- | ---: | ---: | ---: | +| middle remove | 100 | 0.012 | 0.006 | +| | 1,000 | 0.579 | 0.082 | +| | 10,000 | 51.293 | 2.203 | +| | 100,000 | TIMEOUT | 173.495 | +| middle insert | 100 | 0.013 | 0.007 | +| | 1,000 | 0.581 | 0.080 | +| | 10,000 | 51.822 | 2.075 | +| | 100,000 | TIMEOUT | 162.663 | +| unshift build | 100 | 0.010 | 0.005 | +| | 1,000 | 0.560 | 0.076 | +| | 10,000 | 51.206 | 3.578 | +| | 100,000 | TIMEOUT | 314.602 | + +Every completed Perry checksum matches Node. All candidate processes complete +100,000 operations. Over the shared 1,000-100,000 range, the log/log slopes +and Perry-minus-Node deltas are: + +| workload | Node slope | Perry slope | delta | +| --- | ---: | ---: | ---: | +| middle remove | 1.930 | 1.662 | -0.268 | +| middle insert | 1.841 | 1.653 | -0.187 | +| unshift build | 1.880 | 1.808 | -0.073 | + +## Mechanism and bounded work + +The dense fast paths still pay their required overlapping element move. They +no longer reclassify every live slot afterward. The finisher instead: + +- classifies and barriers only newly inserted values; +- retains exact pointer-free or all-pointer metadata when the insert permits; +- drops position-specific mixed metadata to conservative UNKNOWN in constant + time; +- translates old-generation dirty-page coverage to the moved destination; and +- always revokes the conservative element-shape proof. + +Runtime unit counters cover repeated unshift, middle insertion, and middle +removal. For `n` operations they observe exactly `n` classified layout slots, +including the one-element deleted arrays produced by repeated removal, rather +than the previous sum of all live receiver lengths. + +Moving-GC tests promote an array, insert young pointers with both operations, +run a copying minor and then a full collection, and validate the rewritten +children. A separate old-array witness moves an old-to-young edge across a +remembered-set page boundary. Existing splice/unshift element-shape sabotage +tests continue to prove that mixed-kind replacement cannot retain a stale +proof. + +## Reproduce + +Build Perry and its matching static libraries in the checkout under test, then +run: + +```sh +python3 benchmarks/array-splice-unshift-10087/run.py \ + --perry target/release/perry \ + --node /path/to/node-v26.5.1/bin/node \ + --output benchmarks/array-splice-unshift-10087/result.json +``` + +The runner compiles all three sources with auto-optimization and the compile +cache disabled, executes sizes 100, 1,000, 10,000, and 100,000 sequentially, +checks cross-engine checksums, and records both general and acceptance-range +slopes. diff --git a/benchmarks/array-splice-unshift-10087/after.json b/benchmarks/array-splice-unshift-10087/after.json new file mode 100644 index 0000000000..70ec891e75 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/after.json @@ -0,0 +1,341 @@ +{ + "revision": "ccf2e64dd81b866ae0ad1f35883c556f07f301ff", + "node": "v26.5.1", + "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", + "cpu": "x86_64", + "artifact_sha256": { + "perry": "b6a2057331ddc4e3afdf07e773f88423673afd8bc22c74121b891ac9871e19c7", + "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", + "runtime": "c2a4047b352e088ab5e5d7d26f41b7afb67516a1d642ecc329f392bdec852ba4", + "stdlib": "fd95b86bc8631bb9acbce5d7479392eaa28693f8d685b64a3f6153e20fb390f0", + "array-splice-middle-remove.ts": "0ce71b76ad2801128883bd523af918e27ec4340e73d745582676dc2b77871b26", + "array-splice-middle-insert.ts": "5a17c728ca431cb2aca89d77315b394e2fb02f077c48cb64a8930d10483dd93e", + "array-unshift-build.ts": "8969ee530d12ec1aaa3d2714d86d7d13fa7cfd4d89aa98bf7d684962e4ed152f" + }, + "workloads": { + "array-splice-middle-remove": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.002476898204334356, + "runs": 56916, + "checksum": 658638221, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.006224482576228473, + "runs": 22477, + "checksum": 658638221, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.03759997744360875, + "runs": 3706, + "checksum": 109957063, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.08216087295081949, + "runs": 1705, + "checksum": 109957063, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.6954285833333433, + "runs": 84, + "checksum": 733399264, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 2.2026380999999957, + "runs": 70, + "checksum": 733399264, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 272.2018300000004, + "runs": 7, + "checksum": 452640523, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 173.49499300000002, + "runs": 7, + "checksum": 452640523, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.6777040563242853, + "perry": 1.4763829172626732 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.9298517281128846, + "perry": 1.6623109503925215 + }, + "acceptance_slope_delta": -0.2675407777203631 + }, + "array-splice-middle-insert": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.003342244737721392, + "runs": 41683, + "checksum": 619454386, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.006913433805737594, + "runs": 20271, + "checksum": 619454386, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.043440058441560736, + "runs": 3227, + "checksum": 604367096, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.08023765199999912, + "runs": 1747, + "checksum": 604367096, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.6585673076923075, + "runs": 91, + "checksum": 37547811, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 2.0748618000000136, + "runs": 70, + "checksum": 37547811, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 208.77947399999994, + "runs": 7, + "checksum": 275047240, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 162.66321100000005, + "runs": 7, + "checksum": 275047240, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.5968791352557716, + "perry": 1.4527397560065625 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.8408986991166225, + "perry": 1.6534555648442628 + }, + "acceptance_slope_delta": -0.1874431342723597 + }, + "array-unshift-build": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.003409731674053986, + "runs": 41038, + "checksum": 922626605, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.0054488518114948785, + "runs": 25364, + "checksum": 922626605, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.0557300111420622, + "runs": 2515, + "checksum": 414934349, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.07627028517110118, + "runs": 1829, + "checksum": 414934349, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 3.3082204285714334, + "runs": 49, + "checksum": 183209813, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 3.578292, + "runs": 42, + "checksum": 183209813, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100000, + "ms_per_run": 321.05652299999997, + "runs": 7, + "checksum": 622348785, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100000, + "ms_per_run": 314.60156800000004, + "runs": 7, + "checksum": 622348785, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.6695089191634749, + "perry": 1.5955688065234597 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.8802461840733669, + "perry": 1.8077027563265196 + }, + "acceptance_slope_delta": -0.0725434277468473 + } + } +} diff --git a/changelog.d/10087-array-splice-unshift-layout.md b/changelog.d/10126-array-splice-unshift-layout.md similarity index 100% rename from changelog.d/10087-array-splice-unshift-layout.md rename to changelog.d/10126-array-splice-unshift-layout.md From 0e4298a273309f2b42742483bde0dfb4921c0e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 14:38:43 +0200 Subject: [PATCH 14/36] fix(runtime): root dense array mutation inputs (cherry picked from commit b13e4941274cacdb96ecdf18c8a8ac1c3a249641) --- .../array-splice-middle-insert.ts | 11 ++- .../array-splice-middle-remove.ts | 11 ++- .../array-unshift-build.ts | 11 ++- benchmarks/array-splice-unshift-10087/run.py | 7 +- .../10126-array-splice-unshift-layout.md | 5 +- crates/perry-runtime/src/array/mod.rs | 2 + crates/perry-runtime/src/array/push_pop.rs | 28 +++--- .../perry-runtime/src/array/splice_slice.rs | 91 +++++++++++++---- .../src/gc/tests/copying/splice_unshift.rs | 98 +++++++++++++++++++ 9 files changed, 217 insertions(+), 47 deletions(-) diff --git a/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts b/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts index df40d5c9fa..202a76b18d 100644 --- a/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts +++ b/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts @@ -78,13 +78,14 @@ function benchmarkMain(): void { runs += count; } // Do not depend on Array.sort to compute the median of a sort benchmark. - for (let i = 1; i < samples.length; i++) { - const v = samples[i]; + const sortedSamples = samples.slice(); + for (let i = 1; i < sortedSamples.length; i++) { + const v = sortedSamples[i]; let j = i - 1; - while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } - samples[j + 1] = v; + while (j >= 0 && sortedSamples[j] > v) { sortedSamples[j + 1] = sortedSamples[j]; j--; } + sortedSamples[j + 1] = v; } console.log(JSON.stringify({name: "array-splice-middle-insert", category: "arrays", n, - ms_per_run: samples[3], runs, checksum})); + ms_per_run: sortedSamples[3], samples, runs, checksum})); } benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts b/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts index b9792b0921..2f379a9dcf 100644 --- a/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts +++ b/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts @@ -81,13 +81,14 @@ function benchmarkMain(): void { runs += count; } // Do not depend on Array.sort to compute the median of a sort benchmark. - for (let i = 1; i < samples.length; i++) { - const v = samples[i]; + const sortedSamples = samples.slice(); + for (let i = 1; i < sortedSamples.length; i++) { + const v = sortedSamples[i]; let j = i - 1; - while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } - samples[j + 1] = v; + while (j >= 0 && sortedSamples[j] > v) { sortedSamples[j + 1] = sortedSamples[j]; j--; } + sortedSamples[j + 1] = v; } console.log(JSON.stringify({name: "array-splice-middle-remove", category: "arrays", n, - ms_per_run: samples[3], runs, checksum})); + ms_per_run: sortedSamples[3], samples, runs, checksum})); } benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/array-unshift-build.ts b/benchmarks/array-splice-unshift-10087/array-unshift-build.ts index 52276ab7e7..5e9431d3a3 100644 --- a/benchmarks/array-splice-unshift-10087/array-unshift-build.ts +++ b/benchmarks/array-splice-unshift-10087/array-unshift-build.ts @@ -78,13 +78,14 @@ function benchmarkMain(): void { runs += count; } // Do not depend on Array.sort to compute the median of a sort benchmark. - for (let i = 1; i < samples.length; i++) { - const v = samples[i]; + const sortedSamples = samples.slice(); + for (let i = 1; i < sortedSamples.length; i++) { + const v = sortedSamples[i]; let j = i - 1; - while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } - samples[j + 1] = v; + while (j >= 0 && sortedSamples[j] > v) { sortedSamples[j + 1] = sortedSamples[j]; j--; } + sortedSamples[j + 1] = v; } console.log(JSON.stringify({name: "array-unshift-build", category: "arrays", n, - ms_per_run: samples[3], runs, checksum})); + ms_per_run: sortedSamples[3], samples, runs, checksum})); } benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/run.py b/benchmarks/array-splice-unshift-10087/run.py index d8a880e11b..28ec4fb26b 100644 --- a/benchmarks/array-splice-unshift-10087/run.py +++ b/benchmarks/array-splice-unshift-10087/run.py @@ -123,10 +123,15 @@ def main(): engine: slope([(row["n"], row[engine]["ms_per_run"]) for row in acceptance]) for engine in ("node", "perry") } + acceptance_slope_delta = ( + acceptance_slopes["perry"] - acceptance_slopes["node"] + if all(value is not None for value in acceptance_slopes.values()) + else None + ) result["workloads"][name].update( acceptance_sizes=[row["n"] for row in acceptance], acceptance_slopes=acceptance_slopes, - acceptance_slope_delta=acceptance_slopes["perry"] - acceptance_slopes["node"], + acceptance_slope_delta=acceptance_slope_delta, ) args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") diff --git a/changelog.d/10126-array-splice-unshift-layout.md b/changelog.d/10126-array-splice-unshift-layout.md index 381d2ffbd3..c6e7a8f8d7 100644 --- a/changelog.d/10126-array-splice-unshift-layout.md +++ b/changelog.d/10126-array-splice-unshift-layout.md @@ -2,5 +2,6 @@ Make dense `Array.splice()` and `Array.unshift()` update GC element metadata from only the inserted slots instead of rebuilding it from every live element. Pointer-free and all-pointer layouts remain exact, mixed layouts fall back to conservative scanning, and old-array dirty-page coverage follows moved -survivors. Repeated middle splice and front insertion no longer time out at -100,000 operations. +survivors. Splice and variadic-unshift inputs are rooted across allocating +steps. Repeated middle splice and front insertion no longer time out at 100,000 +operations. diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index e00ce97b83..e98d3efd54 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -254,6 +254,8 @@ pub use self::search::{ pub use self::sort::{ js_array_sort_default, js_array_sort_with_comparator, js_validate_array_comparator, }; +#[cfg(test)] +pub(crate) use self::splice_slice::test_collect_after_splice_roots_once; pub use self::splice_slice::{ js_array_slice, js_array_slice_values, js_array_splice, js_array_splice_delete_count, }; diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index cf970eb277..942d6c62bd 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -1748,26 +1748,29 @@ pub extern "C" fn js_array_unshift_variadic( return arr; } let scope = crate::gc::RuntimeHandleScope::new(); - let _arr_handle = scope.root_raw_mut_ptr(arr); - // Copy the items out before any grow can move arena memory; `items` - // points at a caller-owned alloca, so it is stable, but we read it - // before mutating to keep the logic simple. - let item_vec: Vec = unsafe { + let arr_handle = scope.root_raw_mut_ptr(arr); + // The caller-owned alloca itself is stable, but a copying collection can + // move any pointer values stored in it without rewriting those raw words. + // Give every item a mutable runtime root before growth can allocate. + let item_handles = unsafe { if items.is_null() { Vec::new() } else { - std::slice::from_raw_parts(items, count as usize).to_vec() + scope.root_nanbox_f64_slice(std::slice::from_raw_parts(items, count as usize)) } }; - let n = item_vec.len(); + let n = item_handles.len(); unsafe { - let length = (*arr).length; - let capacity = (*arr).capacity; + let current = arr_handle.get_raw_mut_ptr::(); + let length = (*current).length; + let capacity = (*current).capacity; let arr = if length + n as u32 > capacity { - js_array_grow(arr, length + n as u32) + js_array_grow(current, length + n as u32) } else { - arr + current }; + arr_handle.set_raw_mut_ptr(arr); + let arr = arr_handle.get_raw_mut_ptr::(); let flags = array_object_flags_resolved(arr); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; // Shift existing elements up by `n`. @@ -1777,7 +1780,8 @@ pub extern "C" fn js_array_unshift_variadic( // Write items in source order at the front. #5552: demote each // uniquely-owned string before it aliases its slot (no-op for SSO / // non-string). - for (i, v) in item_vec.into_iter().enumerate() { + for (i, value) in item_handles.iter().enumerate() { + let v = value.get_nanbox_f64(); crate::string::js_string_addref_if_heap_string(v); let v = canonicalize_array_numeric_store_value_from_flags(flags, v); // GC_STORE_AUDIT(BARRIERED): inserted slots are covered by the diff --git a/crates/perry-runtime/src/array/splice_slice.rs b/crates/perry-runtime/src/array/splice_slice.rs index 4b6f680130..e810b0a8a0 100644 --- a/crates/perry-runtime/src/array/splice_slice.rs +++ b/crates/perry-runtime/src/array/splice_slice.rs @@ -2,6 +2,16 @@ use super::*; use std::ptr; +#[cfg(test)] +thread_local! { + static SPLICE_COLLECT_AFTER_ROOTING_ONCE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn test_collect_after_splice_roots_once() { + SPLICE_COLLECT_AFTER_ROOTING_ONCE.with(|armed| armed.set(true)); +} + /// Splice an array - removes elements and optionally inserts new ones /// start: starting index (can be negative for from-end) /// delete_count: number of elements to delete @@ -70,17 +80,42 @@ pub extern "C" fn js_array_splice( (delete_count as u32).min(len as u32 - start_idx) }; + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_handle = scope.root_raw_mut_ptr(arr); + // `items` points at caller-owned raw storage. The storage address is + // stable, but an evacuating collection cannot rewrite pointer values + // inside it, so root every value before species creation can allocate + // or invoke user code. + let item_handles = if items.is_null() { + Vec::new() + } else { + scope.root_nanbox_f64_slice(std::slice::from_raw_parts(items, items_count as usize)) + }; + #[cfg(test)] + SPLICE_COLLECT_AFTER_ROOTING_ONCE.with(|armed| { + if armed.replace(false) { + crate::gc::gc_collect_minor(); + } + }); + // Create array of deleted elements via ArraySpeciesCreate (ECMA-262 // §23.1.3.31 step 11): reads `O.constructor` / `@@species` and throws // on a poisoned getter or non-constructor species before the receiver // is mutated. - let recv_value = f64::from_bits(crate::value::JSValue::pointer(arr as *const u8).bits()); + let recv_value = + f64::from_bits( + crate::value::JSValue::pointer( + arr_handle.get_raw_mut_ptr::() as *const u8 + ) + .bits(), + ); let deleted_box = crate::array::species::array_species_create(recv_value, actual_delete as usize); - let deleted_is_plain = crate::array::species::species_result_is_plain_array(deleted_box); - let deleted = crate::value::js_nanbox_get_pointer(deleted_box) as *mut ArrayHeader; - - let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; + let deleted_handle = scope.root_nanbox_f64(deleted_box); + let deleted_is_plain = + crate::array::species::species_result_is_plain_array(deleted_handle.get_nanbox_f64()); + let removed_value_handle = + scope.root_nanbox_f64(f64::from_bits(crate::value::TAG_UNDEFINED)); // Copy deleted elements to return array. ECMA-262 §23.1.3.31 step // 12.b: each removed index goes through HasProperty/Get — a hole @@ -88,6 +123,9 @@ pub extern "C" fn js_array_splice( // property of the deleted array (test262 splice/S15.4.4.12_A4_T3); // a genuinely absent index stays a hole. let spec_read = |i: usize| -> f64 { + let arr = arr_handle.get_raw_mut_ptr::(); + let elements_ptr = + crate::array::array_elements_ptr(arr as *const ArrayHeader) as *const f64; let v = *elements_ptr.add(start_idx as usize + i); if v.to_bits() == crate::value::TAG_HOLE { let idx = start_idx + i as u32; @@ -98,28 +136,44 @@ pub extern "C" fn js_array_splice( v }; if deleted_is_plain { + let deleted = crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; (*deleted).length = actual_delete; - let deleted_elements = - crate::array::array_elements_ptr(deleted as *const ArrayHeader) as *mut f64; // Hole reads also consult recorded custom prototypes, which are // not covered by array_iteration_is_exotic's canonical-proto flags. - let src_exotic = crate::array::array_iteration_is_exotic(arr) - || crate::object::prototype_chain::array_static_proto_recorded(); + let src_exotic = crate::array::array_iteration_is_exotic( + arr_handle.get_raw_mut_ptr::(), + ) || crate::object::prototype_chain::array_static_proto_recorded(); for i in 0..actual_delete as usize { let value = spec_read(i); if src_exotic { // Publish before the next getter can collect or throw, // leaving the species result reachable with a partial copy. + let deleted = + crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; note_array_slot(deleted, i, value.to_bits()); } else { // GC_STORE_AUDIT(BARRIERED): no source callbacks; layout/barrier rebuild follows the copy. + let deleted = + crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; + let deleted_elements = + crate::array::array_elements_ptr(deleted as *const ArrayHeader) as *mut f64; ptr::write(deleted_elements.add(i), value); } } + let deleted = crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; rebuild_array_layout(deleted); } else { for i in 0..actual_delete as usize { - crate::array::species::species_result_set(deleted_box, i, spec_read(i)); + removed_value_handle.set_nanbox_f64(spec_read(i)); + crate::array::species::species_result_set( + deleted_handle.get_nanbox_f64(), + i, + removed_value_handle.get_nanbox_f64(), + ); } } @@ -127,11 +181,14 @@ pub extern "C" fn js_array_splice( let new_len = len as u32 - actual_delete + items_count; // Grow array if needed - let arr = if new_len > (*arr).capacity { - js_array_grow(arr, new_len) + let current = arr_handle.get_raw_mut_ptr::(); + let arr = if new_len > (*current).capacity { + js_array_grow(current, new_len) } else { - arr + current }; + arr_handle.set_raw_mut_ptr(arr); + let arr = arr_handle.get_raw_mut_ptr::(); let flags = array_object_flags_resolved(arr); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; @@ -149,9 +206,9 @@ pub extern "C" fn js_array_splice( } // Insert new items - if items_count > 0 && !items.is_null() { - for i in 0..items_count as usize { - let item = *items.add(i); + if items_count > 0 && !item_handles.is_empty() { + for (i, item_handle) in item_handles.iter().enumerate() { + let item = item_handle.get_nanbox_f64(); // A uniquely-owned string spliced in now aliases the array slot — // demote it to shared so a later `s += x` doesn't mutate it in // place. No-op for SSO / non-string. (This insert path doesn't @@ -185,7 +242,7 @@ pub extern "C" fn js_array_splice( // Return modified array via out param *out_arr = arr; - deleted + crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) as *mut ArrayHeader } } diff --git a/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs b/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs index 43ffa1e85c..dcd5761a27 100644 --- a/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs +++ b/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs @@ -95,3 +95,101 @@ fn old_array_unshift_translates_a_young_edge_across_a_page_boundary() { assert!(crate::arena::pointer_in_nursery(child_after)); js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); } + +#[test] +fn splice_roots_receiver_and_inserted_pointer_values_across_species_allocation() { + let _guard = CopyingNurseryTestGuard::new(3); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + register_runtime_handle_root_scanner_for_tests(); + let mut arr = array::js_array_alloc(4); + arr = array::js_array_push_f64(arr, 17.0); + let first = young_leaf(); + let second = young_leaf(); + assert!(crate::arena::pointer_in_nursery(arr as usize)); + assert!(crate::arena::pointer_in_nursery(first)); + assert!(crate::arena::pointer_in_nursery(second)); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + js_shadow_slot_set(1, ptr_bits(first)); + js_shadow_slot_set(2, ptr_bits(second)); + + // The raw `items` alloca below is deliberately not a root. Force a moving + // minor at the exact point where splice has promised to establish its own + // mutable handles and before species creation performs an allocation. + crate::array::test_collect_after_splice_roots_once(); + let collections_before = gc_collection_count(); + let items = [ + f64::from_bits(ptr_bits(first)), + f64::from_bits(ptr_bits(second)), + ]; + let mut out = arr; + let deleted = array::js_array_splice(arr, 1, 0, items.as_ptr(), 2, &mut out); + + assert!(gc_collection_count() > collections_before); + let rooted_arr = (js_shadow_slot_get(0) & POINTER_MASK) as *mut ArrayHeader; + let first_after = (js_shadow_slot_get(1) & POINTER_MASK) as usize; + let second_after = (js_shadow_slot_get(2) & POINTER_MASK) as usize; + assert_ne!(rooted_arr, arr, "the receiver fixture must move"); + assert_eq!(out, rooted_arr, "splice must return the rewritten receiver"); + assert_ne!(first_after, first, "the first inserted fixture must move"); + assert_ne!( + second_after, second, + "the second inserted fixture must move" + ); + assert_eq!( + array::js_array_get_f64(out, 1).to_bits() & POINTER_MASK, + first_after as u64 + ); + assert_eq!( + array::js_array_get_f64(out, 2).to_bits() & POINTER_MASK, + second_after as u64 + ); + assert_eq!(array::js_array_length(deleted), 0); + for slot in 0..3 { + js_shadow_slot_set(slot, crate::value::TAG_UNDEFINED); + } +} + +#[test] +fn old_array_splice_translates_a_young_edge_left_across_a_page_boundary() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let arr = array::js_array_alloc(OLD_BORN_ELEMENTS); + assert!(crate::arena::pointer_in_old_gen(arr as usize)); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + let slots = unsafe { array::array_elements_ptr(arr) }; + let source_index = (1..OLD_BORN_ELEMENTS as usize) + .find(|&index| { + crate::arena::generation_page_for_addr(unsafe { slots.add(index - 1) } as usize) + != crate::arena::generation_page_for_addr(unsafe { slots.add(index) } as usize) + }) + .expect("old-born array must span an element page boundary"); + for index in 0..source_index { + assert_eq!(array::js_array_push_f64(arr, index as f64), arr); + } + let child = young_leaf(); + assert_eq!( + array::js_array_push_f64(arr, f64::from_bits(ptr_bits(child))), + arr + ); + assert_eq!(array::js_array_length(arr) as usize, source_index + 1); + + let mut out = arr; + let deleted = array::js_array_splice(arr, 0, 1, std::ptr::null(), 0, &mut out); + assert_eq!(out, arr); + assert_eq!(array::js_array_length(deleted), 1); + let destination = unsafe { slots.add(source_index - 1) }; + assert_ne!( + crate::arena::generation_page_for_addr(destination as usize), + crate::arena::generation_page_for_addr(unsafe { destination.add(1) } as usize), + "the moved child must cross into a different remembered-set page" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let child_after = + (array::js_array_get_f64(arr, (source_index - 1) as u32).to_bits() & POINTER_MASK) as usize; + assert_ne!(child_after, child); + assert!(crate::arena::pointer_in_nursery(child_after)); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} From ec3bd66a53eb0e40b542996c9f1e9d5ffed821b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 14:58:14 +0200 Subject: [PATCH 15/36] docs: preserve raw array benchmark samples (cherry picked from commit 5661aa31088709749fe9b973ae1bfd8040f6dc9e) --- .../array-splice-unshift-10087/README.md | 42 +- .../array-splice-unshift-10087/after.json | 338 ++++++++++++++--- .../array-splice-unshift-10087/before.json | 358 +++++++++++++++--- 3 files changed, 603 insertions(+), 135 deletions(-) diff --git a/benchmarks/array-splice-unshift-10087/README.md b/benchmarks/array-splice-unshift-10087/README.md index 9ae56f65eb..c9b8271590 100644 --- a/benchmarks/array-splice-unshift-10087/README.md +++ b/benchmarks/array-splice-unshift-10087/README.md @@ -4,26 +4,27 @@ This directory preserves the three standalone issue reproducers and the raw before/after results. Both sweeps used the unchanged TypeScript sources, Node v26.5.1, matching release-built Perry artifacts, a 60-second timeout per process, and a quiet Linux x86_64 host. The JSON artifacts record the exact -source, compiler, runtime, stdlib, and Node hashes. +source, compiler, runtime, stdlib, and Node hashes, plus all seven raw samples +behind every reported median. ## Result Perry milliseconds per workload invocation: -| workload | n | main `50e08e91dd` | candidate `ccf2e64dd8` | +| workload | n | main `50e08e91dd` | candidate `b13e494127` | | --- | ---: | ---: | ---: | -| middle remove | 100 | 0.012 | 0.006 | -| | 1,000 | 0.579 | 0.082 | -| | 10,000 | 51.293 | 2.203 | -| | 100,000 | TIMEOUT | 173.495 | -| middle insert | 100 | 0.013 | 0.007 | -| | 1,000 | 0.581 | 0.080 | -| | 10,000 | 51.822 | 2.075 | -| | 100,000 | TIMEOUT | 162.663 | +| middle remove | 100 | 0.011 | 0.009 | +| | 1,000 | 0.568 | 0.110 | +| | 10,000 | 50.089 | 2.487 | +| | 100,000 | 4,943.798 | 173.040 | +| middle insert | 100 | 0.012 | 0.010 | +| | 1,000 | 0.559 | 0.110 | +| | 10,000 | 50.121 | 2.403 | +| | 100,000 | 4,924.992 | 166.133 | | unshift build | 100 | 0.010 | 0.005 | -| | 1,000 | 0.560 | 0.076 | -| | 10,000 | 51.206 | 3.578 | -| | 100,000 | TIMEOUT | 314.602 | +| | 1,000 | 0.552 | 0.078 | +| | 10,000 | 50.885 | 3.540 | +| | 100,000 | TIMEOUT | 315.464 | Every completed Perry checksum matches Node. All candidate processes complete 100,000 operations. Over the shared 1,000-100,000 range, the log/log slopes @@ -31,9 +32,9 @@ and Perry-minus-Node deltas are: | workload | Node slope | Perry slope | delta | | --- | ---: | ---: | ---: | -| middle remove | 1.930 | 1.662 | -0.268 | -| middle insert | 1.841 | 1.653 | -0.187 | -| unshift build | 1.880 | 1.808 | -0.073 | +| middle remove | 1.923 | 1.598 | -0.324 | +| middle insert | 1.836 | 1.590 | -0.246 | +| unshift build | 1.886 | 1.803 | -0.082 | ## Mechanism and bounded work @@ -54,10 +55,11 @@ than the previous sum of all live receiver lengths. Moving-GC tests promote an array, insert young pointers with both operations, run a copying minor and then a full collection, and validate the rewritten -children. A separate old-array witness moves an old-to-young edge across a -remembered-set page boundary. Existing splice/unshift element-shape sabotage -tests continue to prove that mixed-kind replacement cannot retain a stale -proof. +children. Old-array witnesses move old-to-young edges across remembered-set +page boundaries in both directions. A forced-evacuation splice test also proves +that its receiver and caller-provided pointer items are rooted before species +allocation. Existing splice/unshift element-shape sabotage tests continue to +prove that mixed-kind replacement cannot retain a stale proof. ## Reproduce diff --git a/benchmarks/array-splice-unshift-10087/after.json b/benchmarks/array-splice-unshift-10087/after.json index 70ec891e75..a2db1b9bf3 100644 --- a/benchmarks/array-splice-unshift-10087/after.json +++ b/benchmarks/array-splice-unshift-10087/after.json @@ -1,16 +1,16 @@ { - "revision": "ccf2e64dd81b866ae0ad1f35883c556f07f301ff", + "revision": "b13e4941274cacdb96ecdf18c8a8ac1c3a249641", "node": "v26.5.1", "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", "cpu": "x86_64", "artifact_sha256": { - "perry": "b6a2057331ddc4e3afdf07e773f88423673afd8bc22c74121b891ac9871e19c7", + "perry": "75a684a7db1880f68087fd54ab02a777b4e5a4bf288fb228c1eda7d4c9bf7a2a", "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", - "runtime": "c2a4047b352e088ab5e5d7d26f41b7afb67516a1d642ecc329f392bdec852ba4", - "stdlib": "fd95b86bc8631bb9acbce5d7479392eaa28693f8d685b64a3f6153e20fb390f0", - "array-splice-middle-remove.ts": "0ce71b76ad2801128883bd523af918e27ec4340e73d745582676dc2b77871b26", - "array-splice-middle-insert.ts": "5a17c728ca431cb2aca89d77315b394e2fb02f077c48cb64a8930d10483dd93e", - "array-unshift-build.ts": "8969ee530d12ec1aaa3d2714d86d7d13fa7cfd4d89aa98bf7d684962e4ed152f" + "runtime": "982e96026b19132f6145613ac57fd2286d53d7834a90b91e472ffa7457614479", + "stdlib": "b493fc0807926373714ed18627fc60d6bdcf73fc531730724babfa62b268d888", + "array-splice-middle-remove.ts": "84ddb1f66507c1c24cedb3dbc1960be4d48623bab6ec1da62bc7a53d864be69c", + "array-splice-middle-insert.ts": "ae8f30859cfe485088dea495d3e0b1ab46a23e0a46a0f9e46d405c85f610ba9c", + "array-unshift-build.ts": "5cdae1279d49a661dc46ea4fe3d06c4687b6f30c57a52e958537d2beb8619498" }, "workloads": { "array-splice-middle-remove": { @@ -21,8 +21,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.002476898204334356, - "runs": 56916, + "ms_per_run": 0.0025405870697315716, + "samples": [ + 0.0024564990174402914, + 0.002464419172005575, + 0.002473125015457144, + 0.002541437484117034, + 0.0025405870697315716, + 0.0025459588849293334, + 0.0025425388331002732 + ], + "runs": 55811, "checksum": 658638221, "status": "OK" }, @@ -30,8 +39,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.006224482576228473, - "runs": 22477, + "ms_per_run": 0.009135699543379566, + "samples": [ + 0.009159105263157228, + 0.009067203535811811, + 0.009121569995440684, + 0.009135699543379566, + 0.009122577747377134, + 0.009141752285194121, + 0.0092402618937666 + ], + "runs": 15320, "checksum": 658638221, "status": "OK" } @@ -42,8 +60,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.03759997744360875, - "runs": 3706, + "ms_per_run": 0.03768179284368979, + "samples": [ + 0.03768179284368979, + 0.03765193421052479, + 0.03765776879699321, + 0.03769649717514232, + 0.03765063157894624, + 0.03991032868525909, + 0.037761666037736306 + ], + "runs": 3690, "checksum": 109957063, "status": "OK" }, @@ -51,8 +78,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.08216087295081949, - "runs": 1705, + "ms_per_run": 0.11007884065934202, + "samples": [ + 0.10950257377049058, + 0.11014532967033025, + 0.11007884065934202, + 0.11006437362637247, + 0.11077464640883879, + 0.11021614285714393, + 0.10930843715846499 + ], + "runs": 1275, "checksum": 109957063, "status": "OK" } @@ -63,7 +99,16 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 1.6954285833333433, + "ms_per_run": 1.7180433333333316, + "samples": [ + 1.7230694999999987, + 1.7218213333333285, + 1.7124906666666675, + 1.7180433333333316, + 1.7170448333333372, + 1.7096437499999884, + 1.7181803333333316 + ], "runs": 84, "checksum": 733399264, "status": "OK" @@ -72,8 +117,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 2.2026380999999957, - "runs": 70, + "ms_per_run": 2.486682222222214, + "samples": [ + 2.4847418888888786, + 2.490705555555558, + 2.5029930000000036, + 2.4799308888888794, + 2.484350222222228, + 2.489072444444452, + 2.486682222222214 + ], + "runs": 63, "checksum": 733399264, "status": "OK" } @@ -84,7 +138,16 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100000, - "ms_per_run": 272.2018300000004, + "ms_per_run": 263.7644519999999, + "samples": [ + 263.7644519999999, + 481.3882269999999, + 226.5809180000001, + 164.7803789999998, + 673.9699439999995, + 321.06778099999974, + 169.5935899999995 + ], "runs": 7, "checksum": 452640523, "status": "OK" @@ -93,7 +156,16 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100000, - "ms_per_run": 173.49499300000002, + "ms_per_run": 173.0399319999999, + "samples": [ + 173.0399319999999, + 172.86628800000017, + 173.13674300000002, + 173.55170700000008, + 173.03698600000007, + 173.15512799999988, + 173.01388399999996 + ], "runs": 7, "checksum": 452640523, "status": "OK" @@ -107,8 +179,8 @@ 100000 ], "common_slopes": { - "node": 1.6777040563242853, - "perry": 1.4763829172626732 + "node": 1.6707749099213387, + "perry": 1.4186130025909889 }, "acceptance_sizes": [ 1000, @@ -116,10 +188,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.9298517281128846, - "perry": 1.6623109503925215 + "node": 1.9225423534116022, + "perry": 1.5982212444004196 }, - "acceptance_slope_delta": -0.2675407777203631 + "acceptance_slope_delta": -0.3243211090111826 }, "array-splice-middle-insert": { "rows": [ @@ -129,8 +201,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.003342244737721392, - "runs": 41683, + "ms_per_run": 0.003360681451613015, + "samples": [ + 0.003819098892707317, + 0.003733238895109568, + 0.003360681451613015, + 0.0034471208204067696, + 0.003305355313171618, + 0.003314140016569791, + 0.0033337141666668043 + ], + "runs": 40436, "checksum": 619454386, "status": "OK" }, @@ -138,8 +219,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.006913433805737594, - "runs": 20271, + "ms_per_run": 0.009884597826086776, + "samples": [ + 0.009884597826086776, + 0.009905916831683547, + 0.009921906250000062, + 0.009916924144770045, + 0.00985543940886625, + 0.00983733235004839, + 0.0098533975369444 + ], + "runs": 14171, "checksum": 619454386, "status": "OK" } @@ -150,8 +240,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.043440058441560736, - "runs": 3227, + "ms_per_run": 0.04378578555798565, + "samples": [ + 0.04376526039387338, + 0.043817509846825864, + 0.04373465720524061, + 0.04384112472647751, + 0.04378578555798565, + 0.04391202850877132, + 0.04376722538293241 + ], + "runs": 3199, "checksum": 604367096, "status": "OK" }, @@ -159,8 +258,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.08023765199999912, - "runs": 1747, + "ms_per_run": 0.10964614207650183, + "samples": [ + 0.11051133701657495, + 0.10972250819672237, + 0.11003782417582603, + 0.1090975489130424, + 0.10960232786885098, + 0.10943691803279208, + 0.10964614207650183 + ], + "runs": 1279, "checksum": 604367096, "status": "OK" } @@ -171,8 +279,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 1.6585673076923075, - "runs": 91, + "ms_per_run": 1.664352076923087, + "samples": [ + 1.664352076923087, + 1.66791784615385, + 1.662816769230775, + 1.6676694166666646, + 1.6629193076923126, + 1.6663218461538394, + 1.6628913846153854 + ], + "runs": 90, "checksum": 37547811, "status": "OK" }, @@ -180,8 +297,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 2.0748618000000136, - "runs": 70, + "ms_per_run": 2.40282822222224, + "samples": [ + 2.4044277777777743, + 2.4235046666666764, + 2.390918111111107, + 2.404943111111095, + 2.40282822222224, + 2.3832682222222212, + 2.3981417777777856 + ], + "runs": 63, "checksum": 37547811, "status": "OK" } @@ -192,7 +318,16 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100000, - "ms_per_run": 208.77947399999994, + "ms_per_run": 205.7514940000001, + "samples": [ + 302.22065499999985, + 402.721581, + 158.529407, + 158.74879600000008, + 158.78464299999996, + 205.7514940000001, + 214.83150700000033 + ], "runs": 7, "checksum": 275047240, "status": "OK" @@ -201,7 +336,16 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100000, - "ms_per_run": 162.66321100000005, + "ms_per_run": 166.13343700000019, + "samples": [ + 166.36665200000004, + 166.12051199999985, + 166.06404700000007, + 166.5383519999998, + 166.39583599999992, + 166.13343700000019, + 165.91387699999973 + ], "runs": 7, "checksum": 275047240, "status": "OK" @@ -215,8 +359,8 @@ 100000 ], "common_slopes": { - "node": 1.5968791352557716, - "perry": 1.4527397560065625 + "node": 1.5940659001650173, + "perry": 1.4017223506825347 }, "acceptance_sizes": [ 1000, @@ -224,10 +368,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.8408986991166225, - "perry": 1.6534555648442628 + "node": 1.8360049258129294, + "perry": 1.5902318471968648 }, - "acceptance_slope_delta": -0.1874431342723597 + "acceptance_slope_delta": -0.24577307861606457 }, "array-unshift-build": { "rows": [ @@ -237,8 +381,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.003409731674053986, - "runs": 41038, + "ms_per_run": 0.0033534063704949105, + "samples": [ + 0.003368337150555947, + 0.003379406656529634, + 0.0033747317361224405, + 0.003347498577405678, + 0.0033421620718461576, + 0.0033534063704949105, + 0.0033451184144504386 + ], + "runs": 41688, "checksum": 922626605, "status": "OK" }, @@ -246,8 +399,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.0054488518114948785, - "runs": 25364, + "ms_per_run": 0.005212689861871054, + "samples": [ + 0.005188494163423696, + 0.005454522770657258, + 0.0052000985183261635, + 0.005197519490644218, + 0.005460459459459322, + 0.005212689861871054, + 0.005465407650273742 + ], + "runs": 26377, "checksum": 922626605, "status": "OK" } @@ -258,8 +420,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.0557300111420622, - "runs": 2515, + "ms_per_run": 0.05572649303621089, + "samples": [ + 0.05573995543175507, + 0.055737559888580326, + 0.05570228333333426, + 0.055718846796657825, + 0.05572649303621089, + 0.055722515320335106, + 0.05578340111420652 + ], + "runs": 2514, "checksum": 414934349, "status": "OK" }, @@ -267,8 +438,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.07627028517110118, - "runs": 1829, + "ms_per_run": 0.07803557976653756, + "samples": [ + 0.07785138910505841, + 0.07805570817120644, + 0.08106545344129923, + 0.07803557976653756, + 0.07850144313725588, + 0.07801141634241279, + 0.07794947470816979 + ], + "runs": 1787, "checksum": 414934349, "status": "OK" } @@ -279,7 +459,16 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 3.3082204285714334, + "ms_per_run": 3.3173818571428586, + "samples": [ + 3.32080128571429, + 3.32104728571429, + 3.315765857142854, + 3.3159807142857125, + 3.318246142857155, + 3.3173818571428586, + 3.3145892857142973 + ], "runs": 49, "checksum": 183209813, "status": "OK" @@ -288,7 +477,16 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 3.578292, + "ms_per_run": 3.539867166666672, + "samples": [ + 3.5433066666666755, + 3.539867166666672, + 3.558860833333341, + 3.5393678333333205, + 3.5233213333333424, + 3.555624833333335, + 3.52715466666668 + ], "runs": 42, "checksum": 183209813, "status": "OK" @@ -300,7 +498,16 @@ "name": "array-unshift-build", "category": "arrays", "n": 100000, - "ms_per_run": 321.05652299999997, + "ms_per_run": 329.2457489999997, + "samples": [ + 323.849729, + 330.242569, + 329.5708359999999, + 322.57448999999997, + 329.2457489999997, + 323.57050900000013, + 330.8204380000002 + ], "runs": 7, "checksum": 622348785, "status": "OK" @@ -309,7 +516,16 @@ "name": "array-unshift-build", "category": "arrays", "n": 100000, - "ms_per_run": 314.60156800000004, + "ms_per_run": 315.463833, + "samples": [ + 315.74639000000025, + 315.463833, + 315.4709260000004, + 315.01587800000016, + 315.2034980000003, + 315.771518, + 315.00287400000025 + ], "runs": 7, "checksum": 622348785, "status": "OK" @@ -323,8 +539,8 @@ 100000 ], "common_slopes": { - "node": 1.6695089191634749, - "perry": 1.5955688065234597 + "node": 1.6750835726094417, + "perry": 1.6002357371932812 }, "acceptance_sizes": [ 1000, @@ -332,10 +548,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.8802461840733669, - "perry": 1.8077027563265196 + "node": 1.8857292316659116, + "perry": 1.803328457264972 }, - "acceptance_slope_delta": -0.0725434277468473 + "acceptance_slope_delta": -0.08240077440093962 } } } diff --git a/benchmarks/array-splice-unshift-10087/before.json b/benchmarks/array-splice-unshift-10087/before.json index 8ed962075d..6d2e7fd666 100644 --- a/benchmarks/array-splice-unshift-10087/before.json +++ b/benchmarks/array-splice-unshift-10087/before.json @@ -4,13 +4,13 @@ "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", "cpu": "x86_64", "artifact_sha256": { - "perry": "69cdc2258e9e5ae66ac42f1a29e54bb3f44a136e1c3e5632c65b86215a172ed1", + "perry": "085b75cf1739f0bb5b27ad4113b95de8e20bc4e8778d41f5ddd332cfdf4a380d", "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", - "runtime": "c9b6cd15f8ded630ef23e2faf529e94af927df2fd5194f5c51e8ca1d220e8cbc", - "stdlib": "b49927e243be3298d146afd57ce86287be1840829e67940bebc4b1bd928a7be4", - "array-splice-middle-remove.ts": "0ce71b76ad2801128883bd523af918e27ec4340e73d745582676dc2b77871b26", - "array-splice-middle-insert.ts": "5a17c728ca431cb2aca89d77315b394e2fb02f077c48cb64a8930d10483dd93e", - "array-unshift-build.ts": "8969ee530d12ec1aaa3d2714d86d7d13fa7cfd4d89aa98bf7d684962e4ed152f" + "runtime": "e6f01dc8df48a54b719c9dd53e1b39869db38d6c74005728a9011cc751a3afa4", + "stdlib": "7ed97869f80fe648a6751bfbbb7f4c3e3ff930f82097b30f8c9e92cb3ebff632", + "array-splice-middle-remove.ts": "84ddb1f66507c1c24cedb3dbc1960be4d48623bab6ec1da62bc7a53d864be69c", + "array-splice-middle-insert.ts": "ae8f30859cfe485088dea495d3e0b1ab46a23e0a46a0f9e46d405c85f610ba9c", + "array-unshift-build.ts": "5cdae1279d49a661dc46ea4fe3d06c4687b6f30c57a52e958537d2beb8619498" }, "workloads": { "array-splice-middle-remove": { @@ -21,8 +21,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.002542719171116567, - "runs": 55133, + "ms_per_run": 0.00249472371211187, + "samples": [ + 0.002435937522835471, + 0.002449708389466883, + 0.002471917439130469, + 0.0024977552447554728, + 0.002500145874999632, + 0.0025053710384564947, + 0.00249472371211187 + ], + "runs": 56475, "checksum": 658638221, "status": "OK" }, @@ -30,8 +39,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.011850479857819961, - "runs": 11882, + "ms_per_run": 0.011383208309618144, + "samples": [ + 0.01143070114285797, + 0.011408033637399792, + 0.011277388951523061, + 0.011383208309618144, + 0.011377003981797099, + 0.01141198060467844, + 0.011331552661380535 + ], + "runs": 12312, "checksum": 658638221, "status": "OK" } @@ -42,8 +60,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.03876850000000078, - "runs": 3587, + "ms_per_run": 0.03797202277039719, + "samples": [ + 0.037951155597723336, + 0.03796083870967935, + 0.03797202277039719, + 0.0379825616698285, + 0.03798871347248495, + 0.04004274000000078, + 0.037727548022597444 + ], + "runs": 3666, "checksum": 109957063, "status": "OK" }, @@ -51,8 +78,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.5791328000000021, - "runs": 245, + "ms_per_run": 0.5676167222222183, + "samples": [ + 0.5669796111111067, + 0.5668073333333391, + 0.5711263055555489, + 0.5681611111111111, + 0.5676167222222183, + 0.5710699444444474, + 0.566894999999996 + ], + "runs": 252, "checksum": 109957063, "status": "OK" } @@ -63,7 +99,16 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 1.7828065833333302, + "ms_per_run": 1.7079852500000026, + "samples": [ + 1.7042727500000012, + 1.7083501666666667, + 1.7037935000000033, + 1.7079852500000026, + 1.709773750000006, + 1.7070161666666621, + 1.7113901666666749 + ], "runs": 84, "checksum": 733399264, "status": "OK" @@ -72,7 +117,16 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 51.293488000000025, + "ms_per_run": 50.08937000000003, + "samples": [ + 49.99679699999999, + 50.07237700000002, + 50.18052, + 50.08937000000003, + 50.101421000000016, + 50.04572800000011, + 51.700834999999984 + ], "runs": 7, "checksum": 733399264, "status": "OK" @@ -84,25 +138,60 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100000, - "ms_per_run": 249.9612900000002, + "ms_per_run": 235.17973500000016, + "samples": [ + 273.21835899999996, + 498.9045689999998, + 235.17973500000016, + 164.49285999999984, + 163.12091099999998, + 371.33156800000006, + 181.43499800000018 + ], "runs": 7, "checksum": 452640523, "status": "OK" }, "perry": { - "status": "TIMEOUT" + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 4943.797626999993, + "samples": [ + 4937.649572000002, + 4939.901297999997, + 4943.142349000009, + 4950.355308000006, + 4945.045659000003, + 4945.771986, + 4943.797626999993 + ], + "runs": 7, + "checksum": 452640523, + "status": "OK" } } ], "common_sizes": [ 100, 1000, - 10000 + 10000, + 100000 ], "common_slopes": { - "node": 1.422902915830406, - "perry": 1.818163148001364 - } + "node": 1.6576152708249774, + "perry": 1.8859078391884527 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.8959680820188138, + "perry": 1.9700027516255207 + }, + "acceptance_slope_delta": 0.0740346696067069 }, "array-splice-middle-insert": { "rows": [ @@ -112,8 +201,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.003527362257494725, - "runs": 39499, + "ms_per_run": 0.003334171028504863, + "samples": [ + 0.0033959791171476847, + 0.003395647368421259, + 0.0033672392255889843, + 0.0033270003325575354, + 0.003332811198133428, + 0.003334171028504863, + 0.00333260863045679 + ], + "runs": 41736, "checksum": 619454386, "status": "OK" }, @@ -121,8 +219,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.012534371553884009, - "runs": 11235, + "ms_per_run": 0.01175000469759223, + "samples": [ + 0.01179172127283419, + 0.01175000469759223, + 0.011739468309858185, + 0.011673562427072308, + 0.01182523345153636, + 0.011796361438679365, + 0.011681729713950909 + ], + "runs": 11919, "checksum": 619454386, "status": "OK" } @@ -133,8 +240,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.04604668275862028, - "runs": 3046, + "ms_per_run": 0.043903504385966505, + "samples": [ + 0.043887703947368455, + 0.04395610769230603, + 0.04380913566739684, + 0.043903504385966505, + 0.04392143201754369, + 0.04384778774617051, + 0.04390995394736881 + ], + "runs": 3193, "checksum": 604367096, "status": "OK" }, @@ -142,8 +258,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.5808904857142823, - "runs": 245, + "ms_per_run": 0.5587826111111119, + "samples": [ + 0.558756416666665, + 0.5587826111111119, + 0.5624807777777758, + 0.558431083333335, + 0.5613378055555608, + 0.5575438333333315, + 0.5610957499999958 + ], + "runs": 252, "checksum": 604367096, "status": "OK" } @@ -154,8 +279,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 1.754180750000008, - "runs": 84, + "ms_per_run": 1.6686945000000104, + "samples": [ + 1.6703240000000033, + 1.6681704999999927, + 1.6849644999999878, + 1.6688192500000032, + 1.6681629999999972, + 1.667304083333325, + 1.6686945000000104 + ], + "runs": 85, "checksum": 37547811, "status": "OK" }, @@ -163,7 +297,16 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 51.82242600000001, + "ms_per_run": 50.12064800000002, + "samples": [ + 50.00114599999998, + 50.008609000000035, + 50.13255000000004, + 50.214883999999984, + 50.12064800000002, + 49.88314500000001, + 51.556456000000026 + ], "runs": 7, "checksum": 37547811, "status": "OK" @@ -175,25 +318,60 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100000, - "ms_per_run": 214.31949000000031, + "ms_per_run": 205.96261600000025, + "samples": [ + 266.61390600000004, + 356.35086, + 159.38835199999994, + 158.8593760000001, + 158.71071800000004, + 205.96261600000025, + 215.29663699999992 + ], "runs": 7, "checksum": 275047240, "status": "OK" }, "perry": { - "status": "TIMEOUT" + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 4924.99224, + "samples": [ + 4925.511021999999, + 4924.99224, + 4924.786892999997, + 4926.199474000001, + 4924.712635999997, + 4935.136634999995, + 4924.367428999998 + ], + "runs": 7, + "checksum": 275047240, + "status": "OK" } } ], "common_sizes": [ 100, 1000, - 10000 + 10000, + 100000 ], "common_slopes": { - "node": 1.348312138582441, - "perry": 1.8082075879008503 - } + "node": 1.5952279224896049, + "perry": 1.8819876325448712 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.8356446061083644, + "perry": 1.9725813339022467 + }, + "acceptance_slope_delta": 0.13693672779388222 }, "array-unshift-build": { "rows": [ @@ -203,8 +381,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.0033888458149777907, - "runs": 41225, + "ms_per_run": 0.0033406375480206107, + "samples": [ + 0.003368958228061221, + 0.0033633711114843864, + 0.003359026196473759, + 0.0033406375480206107, + 0.003335973148765855, + 0.0033394645182839243, + 0.0033378830106807044 + ], + "runs": 41803, "checksum": 922626605, "status": "OK" }, @@ -212,8 +399,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.010258765128205217, - "runs": 13533, + "ms_per_run": 0.009645986981678203, + "samples": [ + 0.009630325950890554, + 0.009626392685274772, + 0.010131870048308933, + 0.009661437952680756, + 0.009654288127413245, + 0.009645986981678203, + 0.009643567984570967 + ], + "runs": 14516, "checksum": 922626605, "status": "OK" } @@ -224,8 +420,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.057028461538462895, - "runs": 2458, + "ms_per_run": 0.05584066295264678, + "samples": [ + 0.05593236312849067, + 0.05595974301675869, + 0.05584066295264678, + 0.055909893854748254, + 0.055752311977715435, + 0.05580886629526279, + 0.055813281337047674 + ], + "runs": 2510, "checksum": 414934349, "status": "OK" }, @@ -233,8 +438,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.5598590833333369, - "runs": 252, + "ms_per_run": 0.5524155945945787, + "samples": [ + 0.5519921621621637, + 0.5522566756756746, + 0.5544745405405482, + 0.5515356486486406, + 0.5524155945945787, + 0.5541402162162168, + 0.5541565135135114 + ], + "runs": 259, "checksum": 414934349, "status": "OK" } @@ -245,8 +459,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 3.357314666666658, - "runs": 42, + "ms_per_run": 3.3230101428571452, + "samples": [ + 3.3717298333333283, + 3.3454908333333435, + 3.3168474285714393, + 3.319139000000007, + 3.3230101428571452, + 3.323241857142859, + 3.315325999999987 + ], + "runs": 47, "checksum": 183209813, "status": "OK" }, @@ -254,7 +477,16 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 51.20603699999998, + "ms_per_run": 50.88452100000001, + "samples": [ + 50.88486299999997, + 50.95875100000001, + 50.88452100000001, + 50.83417799999995, + 50.878190000000075, + 50.93173099999996, + 50.864754999999946 + ], "runs": 7, "checksum": 183209813, "status": "OK" @@ -266,7 +498,16 @@ "name": "array-unshift-build", "category": "arrays", "n": 100000, - "ms_per_run": 328.3525890000001, + "ms_per_run": 316.8560090000001, + "samples": [ + 320.27808200000004, + 316.5288189999999, + 316.8560090000001, + 320.45427100000006, + 316.6757640000001, + 320.1764440000002, + 316.3009940000002 + ], "runs": 7, "checksum": 622348785, "status": "OK" @@ -282,9 +523,18 @@ 10000 ], "common_slopes": { - "node": 1.4979701189328156, - "perry": 1.8491130394091182 - } + "node": 1.498851154351971, + "perry": 1.8611195095979047 + }, + "acceptance_sizes": [ + 1000, + 10000 + ], + "acceptance_slopes": { + "node": 1.7745811011966977, + "perry": 1.964319760517739 + }, + "acceptance_slope_delta": 0.18973865932104128 } } } From 950213e2d798871e9abcfb7bf4e94be8f50645ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:10:27 +0200 Subject: [PATCH 16/36] fix(runtime): scope dense mutation handle reads (cherry picked from commit 51143d8dd8f230569716680763190103eb8869a1) --- crates/perry-runtime/src/array/push_pop.rs | 22 ++- .../perry-runtime/src/array/splice_slice.rs | 143 +++++++++--------- scripts/gc_runtime_root_holders.json | 12 ++ 3 files changed, 95 insertions(+), 82 deletions(-) diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 942d6c62bd..5e090e0032 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -1760,17 +1760,15 @@ pub extern "C" fn js_array_unshift_variadic( } }; let n = item_handles.len(); - unsafe { - let current = arr_handle.get_raw_mut_ptr::(); - let length = (*current).length; - let capacity = (*current).capacity; - let arr = if length + n as u32 > capacity { - js_array_grow(current, length + n as u32) - } else { - current - }; - arr_handle.set_raw_mut_ptr(arr); - let arr = arr_handle.get_raw_mut_ptr::(); + let (length, capacity) = arr_handle.with_mut_ptr::(|current| unsafe { + ((*current).length, (*current).capacity) + }); + if length + n as u32 > capacity { + let grown = arr_handle + .with_mut_ptr::(|current| js_array_grow(current, length + n as u32)); + arr_handle.set_raw_mut_ptr(grown); + } + arr_handle.with_mut_ptr::(|arr| unsafe { let flags = array_object_flags_resolved(arr); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; // Shift existing elements up by `n`. @@ -1798,7 +1796,7 @@ pub extern "C" fn js_array_unshift_variadic( n, ); arr - } + }) } fn unshift_array_spec_path(arr: *mut ArrayHeader, items: &[f64]) -> *mut ArrayHeader { diff --git a/crates/perry-runtime/src/array/splice_slice.rs b/crates/perry-runtime/src/array/splice_slice.rs index e810b0a8a0..48611a9477 100644 --- a/crates/perry-runtime/src/array/splice_slice.rs +++ b/crates/perry-runtime/src/array/splice_slice.rs @@ -102,13 +102,9 @@ pub extern "C" fn js_array_splice( // §23.1.3.31 step 11): reads `O.constructor` / `@@species` and throws // on a poisoned getter or non-constructor species before the receiver // is mutated. - let recv_value = - f64::from_bits( - crate::value::JSValue::pointer( - arr_handle.get_raw_mut_ptr::() as *const u8 - ) - .bits(), - ); + let recv_value = arr_handle.with_mut_ptr::(|arr| { + f64::from_bits(crate::value::JSValue::pointer(arr as *const u8).bits()) + }); let deleted_box = crate::array::species::array_species_create(recv_value, actual_delete as usize); let deleted_handle = scope.root_nanbox_f64(deleted_box); @@ -123,14 +119,19 @@ pub extern "C" fn js_array_splice( // property of the deleted array (test262 splice/S15.4.4.12_A4_T3); // a genuinely absent index stays a hole. let spec_read = |i: usize| -> f64 { - let arr = arr_handle.get_raw_mut_ptr::(); - let elements_ptr = - crate::array::array_elements_ptr(arr as *const ArrayHeader) as *const f64; - let v = *elements_ptr.add(start_idx as usize + i); + let v = arr_handle.with_mut_ptr::(|arr| { + let elements_ptr = + crate::array::array_elements_ptr(arr as *const ArrayHeader) as *const f64; + *elements_ptr.add(start_idx as usize + i) + }); if v.to_bits() == crate::value::TAG_HOLE { let idx = start_idx + i as u32; - if crate::array::array_spec_has_index(arr, idx) { - return crate::array::array_spec_get(arr, idx); + if arr_handle.with_mut_ptr::(|arr| { + crate::array::array_spec_has_index(arr, idx) + }) { + return arr_handle.with_mut_ptr::(|arr| { + crate::array::array_spec_get(arr, idx) + }); } } v @@ -141,9 +142,9 @@ pub extern "C" fn js_array_splice( (*deleted).length = actual_delete; // Hole reads also consult recorded custom prototypes, which are // not covered by array_iteration_is_exotic's canonical-proto flags. - let src_exotic = crate::array::array_iteration_is_exotic( - arr_handle.get_raw_mut_ptr::(), - ) || crate::object::prototype_chain::array_static_proto_recorded(); + let src_exotic = arr_handle + .with_mut_ptr::(|arr| crate::array::array_iteration_is_exotic(arr)) + || crate::object::prototype_chain::array_static_proto_recorded(); for i in 0..actual_delete as usize { let value = spec_read(i); if src_exotic { @@ -181,66 +182,68 @@ pub extern "C" fn js_array_splice( let new_len = len as u32 - actual_delete + items_count; // Grow array if needed - let current = arr_handle.get_raw_mut_ptr::(); - let arr = if new_len > (*current).capacity { - js_array_grow(current, new_len) - } else { - current - }; - arr_handle.set_raw_mut_ptr(arr); - let arr = arr_handle.get_raw_mut_ptr::(); - let flags = array_object_flags_resolved(arr); - let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; + let should_grow = + arr_handle.with_mut_ptr::(|current| new_len > (*current).capacity); + if should_grow { + let grown = arr_handle + .with_mut_ptr::(|current| js_array_grow(current, new_len)); + arr_handle.set_raw_mut_ptr(grown); + } + arr_handle.with_mut_ptr::(|arr| { + let flags = array_object_flags_resolved(arr); + let elements_ptr = + crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; - // Shift elements after the splice point - let tail_start = start_idx + actual_delete; - let tail_len = len as u32 - tail_start; + // Shift elements after the splice point + let tail_start = start_idx + actual_delete; + let tail_len = len as u32 - tail_start; - if items_count != actual_delete && tail_len > 0 { - // Need to shift the tail - let src = elements_ptr.add(tail_start as usize); - let dst = elements_ptr.add((start_idx + items_count) as usize); - // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates - // survivor dirty pages below. - ptr::copy(src, dst, tail_len as usize); - } + if items_count != actual_delete && tail_len > 0 { + // Need to shift the tail + let src = elements_ptr.add(tail_start as usize); + let dst = elements_ptr.add((start_idx + items_count) as usize); + // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates + // survivor dirty pages below. + ptr::copy(src, dst, tail_len as usize); + } - // Insert new items - if items_count > 0 && !item_handles.is_empty() { - for (i, item_handle) in item_handles.iter().enumerate() { - let item = item_handle.get_nanbox_f64(); - // A uniquely-owned string spliced in now aliases the array slot — - // demote it to shared so a later `s += x` doesn't mutate it in - // place. No-op for SSO / non-string. (This insert path doesn't - // funnel through `note_array_slot`.) - crate::string::js_string_addref_if_heap_string(item); - let item = canonicalize_array_numeric_store_value_from_flags(flags, item); - // GC_STORE_AUDIT(BARRIERED): inserted items are covered by the - // dense-move finisher below. - ptr::write(elements_ptr.add(start_idx as usize + i), item); + // Insert new items + if items_count > 0 && !item_handles.is_empty() { + for (i, item_handle) in item_handles.iter().enumerate() { + let item = item_handle.get_nanbox_f64(); + // A uniquely-owned string spliced in now aliases the array slot — + // demote it to shared so a later `s += x` doesn't mutate it in + // place. No-op for SSO / non-string. (This insert path doesn't + // funnel through `note_array_slot`.) + crate::string::js_string_addref_if_heap_string(item); + let item = canonicalize_array_numeric_store_value_from_flags(flags, item); + // GC_STORE_AUDIT(BARRIERED): inserted items are covered by the + // dense-move finisher below. + ptr::write(elements_ptr.add(start_idx as usize + i), item); + } } - } - // ECMA-262 §23.1.3.31 step 24: Set(O, "length", …, true) — throws on a - // non-writable `length` (test262 splice/S15.4.4.12_A6.1_T2/T3). - super::push_pop::guard_writable_length(arr); - (*arr).length = new_len; - let moved_count = if items_count != actual_delete { - tail_len as usize - } else { - 0 - }; - finish_array_dense_move_layout( - arr, - elements_ptr.add(tail_start as usize).cast(), - elements_ptr.add((start_idx + items_count) as usize).cast(), - moved_count, - elements_ptr.add(start_idx as usize).cast(), - items_count as usize, - ); + // ECMA-262 §23.1.3.31 step 24: Set(O, "length", …, true) — throws on a + // non-writable `length` (test262 splice/S15.4.4.12_A6.1_T2/T3). + super::push_pop::guard_writable_length(arr); + (*arr).length = new_len; + let moved_count = if items_count != actual_delete { + tail_len as usize + } else { + 0 + }; + finish_array_dense_move_layout( + arr, + elements_ptr.add(tail_start as usize).cast(), + elements_ptr.add((start_idx + items_count) as usize).cast(), + moved_count, + elements_ptr.add(start_idx as usize).cast(), + items_count as usize, + ); - // Return modified array via out param - *out_arr = arr; + // Return modified array via out param + *out_arr = arr; + }); crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) as *mut ArrayHeader } diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 75e447ed80..fa68dfa74e 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -148,6 +148,18 @@ "verdict": "not_a_gc_pointer", "why": "#9794 allocation-site sampling: bytes remaining until the next sample. A `Cell` countdown, decremented per allocation and reset on fire — a quantity, never an address." }, + { + "file": "crates/perry-runtime/src/array/header_gc_slots.rs", + "name": "DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS", + "verdict": "test_only", + "why": "#[cfg(test)] Cell counter for asserting bounded dense-move layout classification work. It stores only a slot count and is absent from shipped binaries." + }, + { + "file": "crates/perry-runtime/src/array/splice_slice.rs", + "name": "SPLICE_COLLECT_AFTER_ROOTING_ONCE", + "verdict": "test_only", + "why": "#[cfg(test)] Cell one-shot flag that forces a collection after splice roots its receiver and inserted values. It stores only true/false and is absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/async_hooks.rs", "name": "ASYNC_HOOK_HANDLES", From eeeaf92a5fd83af2c2acf0f7bf3616696c95cdb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:21:31 +0200 Subject: [PATCH 17/36] docs: refresh final array benchmark evidence (cherry picked from commit 4e5bb3f401f3d7c287f4264ef400f3738045a507) --- .../array-splice-unshift-10087/README.md | 24 +- .../array-splice-unshift-10087/after.json | 450 +++++++++--------- 2 files changed, 237 insertions(+), 237 deletions(-) diff --git a/benchmarks/array-splice-unshift-10087/README.md b/benchmarks/array-splice-unshift-10087/README.md index c9b8271590..001179c345 100644 --- a/benchmarks/array-splice-unshift-10087/README.md +++ b/benchmarks/array-splice-unshift-10087/README.md @@ -11,20 +11,20 @@ behind every reported median. Perry milliseconds per workload invocation: -| workload | n | main `50e08e91dd` | candidate `b13e494127` | +| workload | n | main `50e08e91dd` | candidate `51143d8dd8` | | --- | ---: | ---: | ---: | | middle remove | 100 | 0.011 | 0.009 | | | 1,000 | 0.568 | 0.110 | -| | 10,000 | 50.089 | 2.487 | -| | 100,000 | 4,943.798 | 173.040 | +| | 10,000 | 50.089 | 2.477 | +| | 100,000 | 4,943.798 | 177.435 | | middle insert | 100 | 0.012 | 0.010 | -| | 1,000 | 0.559 | 0.110 | -| | 10,000 | 50.121 | 2.403 | -| | 100,000 | 4,924.992 | 166.133 | +| | 1,000 | 0.559 | 0.109 | +| | 10,000 | 50.121 | 2.387 | +| | 100,000 | 4,924.992 | 167.874 | | unshift build | 100 | 0.010 | 0.005 | -| | 1,000 | 0.552 | 0.078 | -| | 10,000 | 50.885 | 3.540 | -| | 100,000 | TIMEOUT | 315.464 | +| | 1,000 | 0.552 | 0.072 | +| | 10,000 | 50.885 | 3.522 | +| | 100,000 | TIMEOUT | 314.457 | Every completed Perry checksum matches Node. All candidate processes complete 100,000 operations. Over the shared 1,000-100,000 range, the log/log slopes @@ -32,9 +32,9 @@ and Perry-minus-Node deltas are: | workload | Node slope | Perry slope | delta | | --- | ---: | ---: | ---: | -| middle remove | 1.923 | 1.598 | -0.324 | -| middle insert | 1.836 | 1.590 | -0.246 | -| unshift build | 1.886 | 1.803 | -0.082 | +| middle remove | 1.900 | 1.604 | -0.296 | +| middle insert | 1.835 | 1.593 | -0.242 | +| unshift build | 1.877 | 1.821 | -0.056 | ## Mechanism and bounded work diff --git a/benchmarks/array-splice-unshift-10087/after.json b/benchmarks/array-splice-unshift-10087/after.json index a2db1b9bf3..ba073e96c9 100644 --- a/benchmarks/array-splice-unshift-10087/after.json +++ b/benchmarks/array-splice-unshift-10087/after.json @@ -1,13 +1,13 @@ { - "revision": "b13e4941274cacdb96ecdf18c8a8ac1c3a249641", + "revision": "51143d8dd8f230569716680763190103eb8869a1", "node": "v26.5.1", "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", "cpu": "x86_64", "artifact_sha256": { - "perry": "75a684a7db1880f68087fd54ab02a777b4e5a4bf288fb228c1eda7d4c9bf7a2a", + "perry": "6e94371f7413d5b09f016d14341d9aa1032edd668ad77f64339d01a465086a0e", "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", - "runtime": "982e96026b19132f6145613ac57fd2286d53d7834a90b91e472ffa7457614479", - "stdlib": "b493fc0807926373714ed18627fc60d6bdcf73fc531730724babfa62b268d888", + "runtime": "3307053ff5a7fd3a29b855ab6dfa228ab26302bc55ea8cfe7eb2bf0cc4c17530", + "stdlib": "9bc0f598b1f14748f789a65971999ee0fdb024dc82fafa452ed361c26d72143b", "array-splice-middle-remove.ts": "84ddb1f66507c1c24cedb3dbc1960be4d48623bab6ec1da62bc7a53d864be69c", "array-splice-middle-insert.ts": "ae8f30859cfe485088dea495d3e0b1ab46a23e0a46a0f9e46d405c85f610ba9c", "array-unshift-build.ts": "5cdae1279d49a661dc46ea4fe3d06c4687b6f30c57a52e958537d2beb8619498" @@ -21,17 +21,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.0025405870697315716, + "ms_per_run": 0.0024786749287400082, "samples": [ - 0.0024564990174402914, - 0.002464419172005575, - 0.002473125015457144, - 0.002541437484117034, - 0.0025405870697315716, - 0.0025459588849293334, - 0.0025425388331002732 + 0.002446843895277812, + 0.0024436023213195127, + 0.002450094573073629, + 0.0024786749287400082, + 0.0024809967749937645, + 0.002640769738579171, + 0.0025103835822770917 ], - "runs": 55811, + "runs": 56194, "checksum": 658638221, "status": "OK" }, @@ -39,17 +39,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.009135699543379566, + "ms_per_run": 0.008728169284467851, "samples": [ - 0.009159105263157228, - 0.009067203535811811, - 0.009121569995440684, - 0.009135699543379566, - 0.009122577747377134, - 0.009141752285194121, - 0.0092402618937666 + 0.008774080701754117, + 0.008728169284467851, + 0.008727329842931142, + 0.00870101478903937, + 0.008789643233744204, + 0.008676913232103366, + 0.008761766535259022 ], - "runs": 15320, + "runs": 16027, "checksum": 658638221, "status": "OK" } @@ -60,17 +60,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.03768179284368979, + "ms_per_run": 0.03793557765151485, "samples": [ - 0.03768179284368979, - 0.03765193421052479, - 0.03765776879699321, - 0.03769649717514232, - 0.03765063157894624, - 0.03991032868525909, - 0.037761666037736306 + 0.03791529356060524, + 0.037896081439395386, + 0.03792792045454446, + 0.03804671673003776, + 0.03793557765151485, + 0.04010783166332525, + 0.038091678707224026 ], - "runs": 3690, + "runs": 3663, "checksum": 109957063, "status": "OK" }, @@ -78,17 +78,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.11007884065934202, + "ms_per_run": 0.10981515300546119, "samples": [ - 0.10950257377049058, - 0.11014532967033025, - 0.11007884065934202, - 0.11006437362637247, - 0.11077464640883879, - 0.11021614285714393, - 0.10930843715846499 + 0.11006642307692394, + 0.10981515300546119, + 0.10894628260869309, + 0.10992757142857054, + 0.11035808241758774, + 0.10976892349727184, + 0.10951045355191102 ], - "runs": 1275, + "runs": 1279, "checksum": 109957063, "status": "OK" } @@ -99,15 +99,15 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 1.7180433333333316, + "ms_per_run": 1.7151113333333352, "samples": [ - 1.7230694999999987, - 1.7218213333333285, - 1.7124906666666675, - 1.7180433333333316, - 1.7170448333333372, - 1.7096437499999884, - 1.7181803333333316 + 1.7157007499999988, + 1.7155354166666683, + 1.7136701666666596, + 1.749316833333329, + 1.714018416666671, + 1.7151113333333352, + 1.7128304166666577 ], "runs": 84, "checksum": 733399264, @@ -117,15 +117,15 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 2.486682222222214, + "ms_per_run": 2.477415333333321, "samples": [ - 2.4847418888888786, - 2.490705555555558, - 2.5029930000000036, - 2.4799308888888794, - 2.484350222222228, - 2.489072444444452, - 2.486682222222214 + 2.4720384444444314, + 2.477415333333321, + 2.4940116666666716, + 2.4697266666666704, + 2.473392222222199, + 2.4816533333333206, + 2.4934354444444518 ], "runs": 63, "checksum": 733399264, @@ -138,15 +138,15 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100000, - "ms_per_run": 263.7644519999999, + "ms_per_run": 239.24695599999995, "samples": [ - 263.7644519999999, - 481.3882269999999, - 226.5809180000001, - 164.7803789999998, - 673.9699439999995, - 321.06778099999974, - 169.5935899999995 + 273.6883439999997, + 501.7160170000002, + 239.24695599999995, + 167.3009689999999, + 165.79250199999979, + 382.89851, + 187.30233099999987 ], "runs": 7, "checksum": 452640523, @@ -156,15 +156,15 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100000, - "ms_per_run": 173.0399319999999, + "ms_per_run": 177.43527399999994, "samples": [ - 173.0399319999999, - 172.86628800000017, - 173.13674300000002, - 173.55170700000008, - 173.03698600000007, - 173.15512799999988, - 173.01388399999996 + 175.08608700000013, + 176.32856800000013, + 177.8377660000001, + 177.96015299999976, + 177.43527399999994, + 177.75422900000012, + 176.4309179999998 ], "runs": 7, "checksum": 452640523, @@ -179,8 +179,8 @@ 100000 ], "common_slopes": { - "node": 1.6707749099213387, - "perry": 1.4186130025909889 + "node": 1.6609126155737768, + "perry": 1.4277686952049742 }, "acceptance_sizes": [ 1000, @@ -188,10 +188,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.9225423534116022, - "perry": 1.5982212444004196 + "node": 1.89989985965901, + "perry": 1.6041888452427233 }, - "acceptance_slope_delta": -0.3243211090111826 + "acceptance_slope_delta": -0.29571101441628667 }, "array-splice-middle-insert": { "rows": [ @@ -201,17 +201,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.003360681451613015, + "ms_per_run": 0.0033444301956196527, "samples": [ - 0.003819098892707317, - 0.003733238895109568, - 0.003360681451613015, - 0.0034471208204067696, - 0.003305355313171618, - 0.003314140016569791, - 0.0033337141666668043 + 0.0033996445690973043, + 0.0034179883978838915, + 0.0034090879495481206, + 0.003338803204807429, + 0.0033444301956196527, + 0.003334863954651478, + 0.0033433814777667397 ], - "runs": 40436, + "runs": 41563, "checksum": 619454386, "status": "OK" }, @@ -219,17 +219,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.009884597826086776, + "ms_per_run": 0.009911570862240747, "samples": [ - 0.009884597826086776, - 0.009905916831683547, - 0.009921906250000062, - 0.009916924144770045, - 0.00985543940886625, - 0.00983733235004839, - 0.0098533975369444 + 0.009906766716196313, + 0.00994642715067187, + 0.009957457493857359, + 0.009767160156250537, + 0.009911570862240747, + 0.009901594554455355, + 0.01000816758379198 ], - "runs": 14171, + "runs": 14150, "checksum": 619454386, "status": "OK" } @@ -240,17 +240,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.04378578555798565, + "ms_per_run": 0.0440179758241755, "samples": [ - 0.04376526039387338, - 0.043817509846825864, - 0.04373465720524061, - 0.04384112472647751, - 0.04378578555798565, - 0.04391202850877132, - 0.04376722538293241 + 0.043962758241758715, + 0.04403651648351632, + 0.04393292105263207, + 0.043964909890110745, + 0.04417963134657799, + 0.0440179758241755, + 0.04417972185430466 ], - "runs": 3199, + "runs": 3182, "checksum": 604367096, "status": "OK" }, @@ -258,17 +258,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.10964614207650183, + "ms_per_run": 0.10917846739130285, "samples": [ - 0.11051133701657495, - 0.10972250819672237, - 0.11003782417582603, - 0.1090975489130424, - 0.10960232786885098, - 0.10943691803279208, - 0.10964614207650183 + 0.10947957377049196, + 0.10864083243243124, + 0.10917846739130285, + 0.10792587634408808, + 0.1109336906077308, + 0.10989890659340781, + 0.10890227717390975 ], - "runs": 1279, + "runs": 1285, "checksum": 604367096, "status": "OK" } @@ -279,17 +279,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 1.664352076923087, + "ms_per_run": 1.6902569166666648, "samples": [ - 1.664352076923087, - 1.66791784615385, - 1.662816769230775, - 1.6676694166666646, - 1.6629193076923126, - 1.6663218461538394, - 1.6628913846153854 + 1.676083250000005, + 1.6844255000000032, + 1.6775207499999993, + 1.6902569166666648, + 1.791112333333345, + 1.8052902500000034, + 1.8203548181818094 ], - "runs": 90, + "runs": 83, "checksum": 37547811, "status": "OK" }, @@ -297,15 +297,15 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 2.40282822222224, + "ms_per_run": 2.386657888888888, "samples": [ - 2.4044277777777743, - 2.4235046666666764, - 2.390918111111107, - 2.404943111111095, - 2.40282822222224, - 2.3832682222222212, - 2.3981417777777856 + 2.39644633333333, + 2.4205858888888847, + 2.3792675555555527, + 2.399434111111096, + 2.3732842222222086, + 2.386657888888888, + 2.3856461111111003 ], "runs": 63, "checksum": 37547811, @@ -318,15 +318,15 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100000, - "ms_per_run": 205.7514940000001, + "ms_per_run": 206.08473400000003, "samples": [ - 302.22065499999985, - 402.721581, - 158.529407, - 158.74879600000008, - 158.78464299999996, - 205.7514940000001, - 214.83150700000033 + 305.05159000000003, + 405.7424560000002, + 160.37497400000007, + 162.81490899999994, + 161.91186300000027, + 206.08473400000003, + 217.9855480000001 ], "runs": 7, "checksum": 275047240, @@ -336,15 +336,15 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100000, - "ms_per_run": 166.13343700000019, + "ms_per_run": 167.87403699999982, "samples": [ - 166.36665200000004, - 166.12051199999985, - 166.06404700000007, - 166.5383519999998, - 166.39583599999992, - 166.13343700000019, - 165.91387699999973 + 169.553904, + 165.93865300000004, + 165.74730499999987, + 167.87403699999982, + 170.0324280000002, + 170.2465169999998, + 167.04205299999967 ], "runs": 7, "checksum": 275047240, @@ -359,8 +359,8 @@ 100000 ], "common_slopes": { - "node": 1.5940659001650173, - "perry": 1.4017223506825347 + "node": 1.5953493712731313, + "perry": 1.4026176309069263 }, "acceptance_sizes": [ 1000, @@ -368,10 +368,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.8360049258129294, - "perry": 1.5902318471968648 + "node": 1.8352078772385179, + "perry": 1.5934232704187608 }, - "acceptance_slope_delta": -0.24577307861606457 + "acceptance_slope_delta": -0.2417846068197571 }, "array-unshift-build": { "rows": [ @@ -381,17 +381,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.0033534063704949105, + "ms_per_run": 0.0033944065840831976, "samples": [ - 0.003368337150555947, - 0.003379406656529634, - 0.0033747317361224405, - 0.003347498577405678, - 0.0033421620718461576, - 0.0033534063704949105, - 0.0033451184144504386 + 0.0034049019407556653, + 0.003425276588457152, + 0.003409461905572877, + 0.003391798880786283, + 0.0033944065840831976, + 0.00338697595258245, + 0.0033825758498225982 ], - "runs": 41688, + "runs": 41188, "checksum": 922626605, "status": "OK" }, @@ -399,17 +399,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.005212689861871054, + "ms_per_run": 0.004971755406413041, "samples": [ - 0.005188494163423696, - 0.005454522770657258, - 0.0052000985183261635, - 0.005197519490644218, - 0.005460459459459322, - 0.005212689861871054, - 0.005465407650273742 + 0.004971755406413041, + 0.005210262047408393, + 0.004958451412989683, + 0.00521068637666096, + 0.004961171626984339, + 0.005391944303798188, + 0.004961890349789468 ], - "runs": 26377, + "runs": 27748, "checksum": 922626605, "status": "OK" } @@ -420,17 +420,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.05572649303621089, + "ms_per_run": 0.05574202506963839, "samples": [ - 0.05573995543175507, - 0.055737559888580326, - 0.05570228333333426, - 0.055718846796657825, - 0.05572649303621089, - 0.055722515320335106, - 0.05578340111420652 + 0.05575101949860721, + 0.055771139275767524, + 0.05574202506963839, + 0.055686266666669974, + 0.055720356545961434, + 0.055704075000001296, + 0.05574750696378669 ], - "runs": 2514, + "runs": 2515, "checksum": 414934349, "status": "OK" }, @@ -438,17 +438,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.07803557976653756, + "ms_per_run": 0.0716535821428593, "samples": [ - 0.07785138910505841, - 0.07805570817120644, - 0.08106545344129923, - 0.07803557976653756, - 0.07850144313725588, - 0.07801141634241279, - 0.07794947470816979 + 0.07162994642857134, + 0.07296168000000151, + 0.0716535821428593, + 0.07190240860215252, + 0.07156488928571321, + 0.07532254135338412, + 0.07122930960853939 ], - "runs": 1787, + "runs": 1941, "checksum": 414934349, "status": "OK" } @@ -459,17 +459,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 3.3173818571428586, + "ms_per_run": 3.317559571428571, "samples": [ - 3.32080128571429, - 3.32104728571429, - 3.315765857142854, - 3.3159807142857125, - 3.318246142857155, - 3.3173818571428586, - 3.3145892857142973 + 3.317559571428571, + 3.4456023333333214, + 3.7074698333333345, + 3.3151864285714265, + 3.312718999999999, + 3.31008300000002, + 3.3196221428571397 ], - "runs": 49, + "runs": 47, "checksum": 183209813, "status": "OK" }, @@ -477,15 +477,15 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 3.539867166666672, + "ms_per_run": 3.5221938333333327, "samples": [ - 3.5433066666666755, - 3.539867166666672, - 3.558860833333341, - 3.5393678333333205, - 3.5233213333333424, - 3.555624833333335, - 3.52715466666668 + 3.5212676666666596, + 3.5221938333333327, + 3.5471274999999927, + 3.5247338333333573, + 3.515755666666687, + 3.5455643333333264, + 3.514045500000009 ], "runs": 42, "checksum": 183209813, @@ -498,15 +498,15 @@ "name": "array-unshift-build", "category": "arrays", "n": 100000, - "ms_per_run": 329.2457489999997, + "ms_per_run": 316.54610300000013, "samples": [ - 323.849729, - 330.242569, - 329.5708359999999, - 322.57448999999997, - 329.2457489999997, - 323.57050900000013, - 330.8204380000002 + 321.4570640000002, + 312.680965, + 316.54610300000013, + 320.5322369999999, + 313.5053939999998, + 321.06873799999994, + 313.2147420000001 ], "runs": 7, "checksum": 622348785, @@ -516,15 +516,15 @@ "name": "array-unshift-build", "category": "arrays", "n": 100000, - "ms_per_run": 315.463833, + "ms_per_run": 314.45723, "samples": [ - 315.74639000000025, - 315.463833, - 315.4709260000004, - 315.01587800000016, - 315.2034980000003, - 315.771518, - 315.00287400000025 + 314.8736080000001, + 314.02176699999995, + 313.9972419999999, + 314.7522210000002, + 314.171507, + 314.45723, + 314.5252069999997 ], "runs": 7, "checksum": 622348785, @@ -539,8 +539,8 @@ 100000 ], "common_slopes": { - "node": 1.6750835726094417, - "perry": 1.6002357371932812 + "node": 1.6683655318093131, + "perry": 1.6094730835042874 }, "acceptance_sizes": [ 1000, @@ -548,10 +548,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.8857292316659116, - "perry": 1.803328457264972 + "node": 1.8771271145022215, + "perry": 1.8211618388636228 }, - "acceptance_slope_delta": -0.08240077440093962 + "acceptance_slope_delta": -0.055965275638598655 } } } From 2334353b027ff0476d997a4883c4590ce8309ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:37:30 +0200 Subject: [PATCH 18/36] fix(runtime): keep dense moves bounded during marking (cherry picked from commit ae5110381005f6ccc772dc2713ddcafa98b7807b) --- benchmarks/array-splice-unshift-10087/run.py | 29 ++++++++++------ .../src/array/header_gc_slots.rs | 12 +++---- crates/perry-runtime/src/gc/barrier/mod.rs | 34 ++++++++++++++----- crates/perry-runtime/src/gc/tests/barrier.rs | 26 ++++++++++++++ 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/benchmarks/array-splice-unshift-10087/run.py b/benchmarks/array-splice-unshift-10087/run.py index 28ec4fb26b..567db62857 100644 --- a/benchmarks/array-splice-unshift-10087/run.py +++ b/benchmarks/array-splice-unshift-10087/run.py @@ -14,6 +14,7 @@ "array-splice-middle-insert", "array-unshift-build", ) +ACCEPTANCE_SIZES = (1000, 10000, 100000) def artifact_hashes(perry, node, sources): @@ -36,6 +37,21 @@ def slope(rows): ) +def acceptance_summary(common): + acceptance = [row for row in common if row["n"] in ACCEPTANCE_SIZES] + acceptance_complete = [row["n"] for row in acceptance] == list(ACCEPTANCE_SIZES) + slopes = { + engine: ( + slope([(row["n"], row[engine]["ms_per_run"]) for row in acceptance]) + if acceptance_complete + else None + ) + for engine in ("node", "perry") + } + delta = slopes["perry"] - slopes["node"] if acceptance_complete else None + return [row["n"] for row in acceptance], slopes, delta + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--perry", type=Path, required=True) @@ -118,18 +134,9 @@ def main(): for engine in ("node", "perry") }, ) - acceptance = [row for row in common if 1000 <= row["n"] <= 100000] - acceptance_slopes = { - engine: slope([(row["n"], row[engine]["ms_per_run"]) for row in acceptance]) - for engine in ("node", "perry") - } - acceptance_slope_delta = ( - acceptance_slopes["perry"] - acceptance_slopes["node"] - if all(value is not None for value in acceptance_slopes.values()) - else None - ) + acceptance_sizes, acceptance_slopes, acceptance_slope_delta = acceptance_summary(common) result["workloads"][name].update( - acceptance_sizes=[row["n"] for row in acceptance], + acceptance_sizes=acceptance_sizes, acceptance_slopes=acceptance_slopes, acceptance_slope_delta=acceptance_slope_delta, ) diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index 9d4d7cd433..2f32418676 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -295,11 +295,11 @@ pub(crate) unsafe fn rebuild_array_layout(arr: *mut ArrayHeader) { /// revoked: inserted values can change KIND even when the array length does /// not change (#7480). /// -/// Survivor references are not new edges, but an old array's dirty-page -/// coverage follows their byte move. Translate that coverage instead of -/// replaying a write barrier for every survivor. A live incremental mark is -/// the rare case where the translation helper declines; the value-derived -/// replay then preserves its insertion-shading contract. +/// Survivor references are not new parent-child edges, but an old array's +/// dirty-page coverage follows their byte move. Translate that coverage +/// instead of replaying a write barrier for every survivor. This remains +/// page-only during incremental marking: the inserted-slot barriers below +/// perform all shading owed by the operation's genuinely new edges. /// /// # Safety /// @@ -328,7 +328,7 @@ pub(crate) unsafe fn finish_array_dense_move_layout( if moved_count != 0 && moved_src != moved_dst.cast_const() && !pointer_free { let copied_bytes = moved_count * std::mem::size_of::(); - if !crate::gc::relocate_copied_old_object_dirty_pages( + if !crate::gc::relocate_moved_old_object_dirty_pages( arr as usize, moved_src as usize, moved_dst as usize, diff --git a/crates/perry-runtime/src/gc/barrier/mod.rs b/crates/perry-runtime/src/gc/barrier/mod.rs index 52b824dda2..7a78753ca2 100644 --- a/crates/perry-runtime/src/gc/barrier/mod.rs +++ b/crates/perry-runtime/src/gc/barrier/mod.rs @@ -1472,23 +1472,41 @@ pub(super) fn replay_old_parent_slot_range(parent_addr: usize, slots: *mut u64, /// a `u64` slot run — and it is the SAME invariant the minor collector already /// trusts every cycle, not a new assumption. /// -/// Returns `false` when it declines (an incremental cycle is live, so the -/// values also owe SATB shading), and the caller must fall back to the full -/// value-derived replay. +/// Returns `false` when an incremental cycle requires the copied values to be +/// shaded or the source has no old-parent coverage to donate. pub(crate) fn relocate_copied_old_object_dirty_pages( new_parent_addr: usize, old_base: usize, new_base: usize, copied_bytes: usize, ) -> bool { - if copied_bytes == 0 { - return true; - } - // Shading is about values an in-progress mark may not have seen; a page is - // not an answer to it. Hand those cycles back to the full replay. if !incremental_mark_barrier_globally_idle() { return false; } + relocate_old_object_dirty_pages(new_parent_addr, old_base, new_base, copied_bytes) +} + +/// Translate dirty pages for a no-safepoint slot move within one parent. The +/// parent-child edge set survives unchanged, so incremental marking owes no +/// survivor shading; callers separately barrier genuinely inserted values. +pub(crate) fn relocate_moved_old_object_dirty_pages( + parent_addr: usize, + old_base: usize, + new_base: usize, + copied_bytes: usize, +) -> bool { + relocate_old_object_dirty_pages(parent_addr, old_base, new_base, copied_bytes) +} + +fn relocate_old_object_dirty_pages( + new_parent_addr: usize, + old_base: usize, + new_base: usize, + copied_bytes: usize, +) -> bool { + if copied_bytes == 0 { + return true; + } if !write_barriers_enabled() || !barrier_remembering_active() { return true; } diff --git a/crates/perry-runtime/src/gc/tests/barrier.rs b/crates/perry-runtime/src/gc/tests/barrier.rs index 457b90a7f5..4f200a8332 100644 --- a/crates/perry-runtime/src/gc/tests/barrier.rs +++ b/crates/perry-runtime/src/gc/tests/barrier.rs @@ -1948,3 +1948,29 @@ fn dirty_page_translation_only_inherits_from_an_old_gen_source() { the invariant for; refusing it would make the whole translation dead" ); } + +#[test] +fn in_place_dirty_page_translation_stays_bounded_during_incremental_marking() { + let _guard = GcTestIsolationGuard::new(); + reset_remembered_set(); + let parent = crate::arena::arena_alloc_gc_old(24 * 1024, 8, GC_TYPE_ARRAY) as usize; + let source = parent + 1024; + let destination = source + 8192; + let source_page = crate::arena::generation_page_for_addr(source); + let destination_page = crate::arena::generation_page_for_addr(destination); + assert_ne!(source_page, destination_page); + assert!(super::super::barrier::mark_dirty_old_page(source_page)); + assert!(!old_page_dirty_for(destination_page)); + + let valid_ptrs = build_valid_pointer_set(); + let _active = IncrementalMarkBarrierTestGuard::new(&valid_ptrs); + assert!(!incremental_mark_barrier_globally_idle()); + assert!(relocate_moved_old_object_dirty_pages( + parent, + source, + destination, + std::mem::size_of::(), + )); + assert!(old_page_dirty_for(destination_page)); + reset_remembered_set(); +} From 6b6465c3be27e5bcf15dab3bb43c51a5fcbf0606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 15:44:31 +0200 Subject: [PATCH 19/36] docs: refresh post-review benchmark evidence (cherry picked from commit 2a42dd72f1066c310f1527e4f15ecd52ee57e6ae) --- .../array-splice-unshift-10087/README.md | 27 +- .../array-splice-unshift-10087/after.json | 452 +++++++++--------- 2 files changed, 240 insertions(+), 239 deletions(-) diff --git a/benchmarks/array-splice-unshift-10087/README.md b/benchmarks/array-splice-unshift-10087/README.md index 001179c345..948fc39d34 100644 --- a/benchmarks/array-splice-unshift-10087/README.md +++ b/benchmarks/array-splice-unshift-10087/README.md @@ -11,20 +11,20 @@ behind every reported median. Perry milliseconds per workload invocation: -| workload | n | main `50e08e91dd` | candidate `51143d8dd8` | +| workload | n | main `50e08e91dd` | candidate `ae51103810` | | --- | ---: | ---: | ---: | | middle remove | 100 | 0.011 | 0.009 | -| | 1,000 | 0.568 | 0.110 | -| | 10,000 | 50.089 | 2.477 | -| | 100,000 | 4,943.798 | 177.435 | +| | 1,000 | 0.568 | 0.111 | +| | 10,000 | 50.089 | 2.513 | +| | 100,000 | 4,943.798 | 173.967 | | middle insert | 100 | 0.012 | 0.010 | -| | 1,000 | 0.559 | 0.109 | -| | 10,000 | 50.121 | 2.387 | -| | 100,000 | 4,924.992 | 167.874 | +| | 1,000 | 0.559 | 0.107 | +| | 10,000 | 50.121 | 2.365 | +| | 100,000 | 4,924.992 | 165.801 | | unshift build | 100 | 0.010 | 0.005 | | | 1,000 | 0.552 | 0.072 | -| | 10,000 | 50.885 | 3.522 | -| | 100,000 | TIMEOUT | 314.457 | +| | 10,000 | 50.885 | 3.568 | +| | 100,000 | TIMEOUT | 316.252 | Every completed Perry checksum matches Node. All candidate processes complete 100,000 operations. Over the shared 1,000-100,000 range, the log/log slopes @@ -32,9 +32,9 @@ and Perry-minus-Node deltas are: | workload | Node slope | Perry slope | delta | | --- | ---: | ---: | ---: | -| middle remove | 1.900 | 1.604 | -0.296 | -| middle insert | 1.835 | 1.593 | -0.242 | -| unshift build | 1.877 | 1.821 | -0.056 | +| middle remove | 1.929 | 1.597 | -0.332 | +| middle insert | 1.836 | 1.595 | -0.240 | +| unshift build | 1.878 | 1.821 | -0.057 | ## Mechanism and bounded work @@ -45,7 +45,8 @@ no longer reclassify every live slot afterward. The finisher instead: - retains exact pointer-free or all-pointer metadata when the insert permits; - drops position-specific mixed metadata to conservative UNKNOWN in constant time; -- translates old-generation dirty-page coverage to the moved destination; and +- translates old-generation dirty-page coverage to the moved destination; +- keeps same-parent survivor translation page-only during incremental marking; and - always revokes the conservative element-shape proof. Runtime unit counters cover repeated unshift, middle insertion, and middle diff --git a/benchmarks/array-splice-unshift-10087/after.json b/benchmarks/array-splice-unshift-10087/after.json index ba073e96c9..b8213bfcd0 100644 --- a/benchmarks/array-splice-unshift-10087/after.json +++ b/benchmarks/array-splice-unshift-10087/after.json @@ -1,13 +1,13 @@ { - "revision": "51143d8dd8f230569716680763190103eb8869a1", + "revision": "ae5110381005f6ccc772dc2713ddcafa98b7807b", "node": "v26.5.1", "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", "cpu": "x86_64", "artifact_sha256": { - "perry": "6e94371f7413d5b09f016d14341d9aa1032edd668ad77f64339d01a465086a0e", + "perry": "bc24599f3442c20da8d90252ac5b45836866d9c22bc009b4efc3f7a2ef332a87", "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", - "runtime": "3307053ff5a7fd3a29b855ab6dfa228ab26302bc55ea8cfe7eb2bf0cc4c17530", - "stdlib": "9bc0f598b1f14748f789a65971999ee0fdb024dc82fafa452ed361c26d72143b", + "runtime": "882ebd9ffa45f4b915517c10896737daa2b5d72987960832fb36c03506bdaba6", + "stdlib": "95dca2cfcd16106ed70cd5ee3103269bb58f03cafd46558de597870baf77fdc7", "array-splice-middle-remove.ts": "84ddb1f66507c1c24cedb3dbc1960be4d48623bab6ec1da62bc7a53d864be69c", "array-splice-middle-insert.ts": "ae8f30859cfe485088dea495d3e0b1ab46a23e0a46a0f9e46d405c85f610ba9c", "array-unshift-build.ts": "5cdae1279d49a661dc46ea4fe3d06c4687b6f30c57a52e958537d2beb8619498" @@ -21,17 +21,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.0024786749287400082, + "ms_per_run": 0.0025133444332745202, "samples": [ - 0.002446843895277812, - 0.0024436023213195127, - 0.002450094573073629, - 0.0024786749287400082, - 0.0024809967749937645, - 0.002640769738579171, - 0.0025103835822770917 + 0.002462353317740043, + 0.002462383232796072, + 0.002483618030547413, + 0.002514335135134819, + 0.0025196516754848143, + 0.0025137404800804793, + 0.0025133444332745202 ], - "runs": 56194, + "runs": 56107, "checksum": 658638221, "status": "OK" }, @@ -39,17 +39,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100, - "ms_per_run": 0.008728169284467851, + "ms_per_run": 0.008895431302799205, "samples": [ - 0.008774080701754117, - 0.008728169284467851, - 0.008727329842931142, - 0.00870101478903937, - 0.008789643233744204, - 0.008676913232103366, - 0.008761766535259022 + 0.008828160635480226, + 0.00884902830605957, + 0.008860033658103825, + 0.009213449562412991, + 0.009122077519380533, + 0.009007542548401326, + 0.008895431302799205 ], - "runs": 16027, + "runs": 15619, "checksum": 658638221, "status": "OK" } @@ -60,17 +60,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.03793557765151485, + "ms_per_run": 0.03807517680608502, "samples": [ - 0.03791529356060524, - 0.037896081439395386, - 0.03792792045454446, - 0.03804671673003776, - 0.03793557765151485, - 0.04010783166332525, - 0.038091678707224026 + 0.038168251908395075, + 0.03815615238095147, + 0.03802237571157435, + 0.038049735741444546, + 0.03806038022813649, + 0.03998755688622859, + 0.03807517680608502 ], - "runs": 3663, + "runs": 3655, "checksum": 109957063, "status": "OK" }, @@ -78,17 +78,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 1000, - "ms_per_run": 0.10981515300546119, + "ms_per_run": 0.11149689444444245, "samples": [ - 0.11006642307692394, - 0.10981515300546119, - 0.10894628260869309, - 0.10992757142857054, - 0.11035808241758774, - 0.10976892349727184, - 0.10951045355191102 + 0.11149689444444245, + 0.11219140223463442, + 0.11063083977900645, + 0.1113904277777749, + 0.11213929050279349, + 0.1106606243093896, + 0.11193697206703788 ], - "runs": 1279, + "runs": 1259, "checksum": 109957063, "status": "OK" } @@ -99,15 +99,15 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 1.7151113333333352, + "ms_per_run": 1.7450920833333328, "samples": [ - 1.7157007499999988, - 1.7155354166666683, - 1.7136701666666596, - 1.749316833333329, - 1.714018416666671, - 1.7151113333333352, - 1.7128304166666577 + 1.7446035833333344, + 1.758590500000011, + 1.7461690833333374, + 1.7441420833333343, + 1.7512788333333305, + 1.7390290833333257, + 1.7450920833333328 ], "runs": 84, "checksum": 733399264, @@ -117,17 +117,17 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 10000, - "ms_per_run": 2.477415333333321, + "ms_per_run": 2.513367125000002, "samples": [ - 2.4720384444444314, - 2.477415333333321, - 2.4940116666666716, - 2.4697266666666704, - 2.473392222222199, - 2.4816533333333206, - 2.4934354444444518 + 2.493834333333325, + 2.522829624999993, + 2.488911999999996, + 2.513367125000002, + 2.530060750000004, + 2.5020221249999963, + 2.5154386250000016 ], - "runs": 63, + "runs": 58, "checksum": 733399264, "status": "OK" } @@ -138,15 +138,15 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100000, - "ms_per_run": 239.24695599999995, + "ms_per_run": 274.1427759999997, "samples": [ - 273.6883439999997, - 501.7160170000002, - 239.24695599999995, - 167.3009689999999, - 165.79250199999979, - 382.89851, - 187.30233099999987 + 274.1427759999997, + 504.16258000000016, + 236.29019000000017, + 166.20478700000012, + 701.912898, + 336.29722200000015, + 173.49396499999966 ], "runs": 7, "checksum": 452640523, @@ -156,15 +156,15 @@ "name": "array-splice-middle-remove", "category": "arrays", "n": 100000, - "ms_per_run": 177.43527399999994, + "ms_per_run": 173.9668479999999, "samples": [ - 175.08608700000013, - 176.32856800000013, - 177.8377660000001, - 177.96015299999976, - 177.43527399999994, - 177.75422900000012, - 176.4309179999998 + 173.9668479999999, + 174.04900199999975, + 174.45028100000013, + 174.1191819999999, + 173.77322700000013, + 173.91561300000012, + 173.83565299999987 ], "runs": 7, "checksum": 452640523, @@ -179,8 +179,8 @@ 100000 ], "common_slopes": { - "node": 1.6609126155737768, - "perry": 1.4277686952049742 + "node": 1.6774350810632053, + "perry": 1.422689161338288 }, "acceptance_sizes": [ 1000, @@ -188,10 +188,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.89989985965901, - "perry": 1.6041888452427233 + "node": 1.928667438735314, + "perry": 1.5966018618993696 }, - "acceptance_slope_delta": -0.29571101441628667 + "acceptance_slope_delta": -0.3320655768359444 }, "array-splice-middle-insert": { "rows": [ @@ -201,17 +201,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.0033444301956196527, + "ms_per_run": 0.0033471489290493907, "samples": [ - 0.0033996445690973043, - 0.0034179883978838915, - 0.0034090879495481206, - 0.003338803204807429, - 0.0033444301956196527, - 0.003334863954651478, - 0.0033433814777667397 + 0.0034134898585309174, + 0.0034000681625019893, + 0.0033815648351652305, + 0.00334616446377711, + 0.0033448700668894877, + 0.0033384650308802503, + 0.0033471489290493907 ], - "runs": 41563, + "runs": 41589, "checksum": 619454386, "status": "OK" }, @@ -219,17 +219,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100, - "ms_per_run": 0.009911570862240747, + "ms_per_run": 0.009758514146341643, "samples": [ - 0.009906766716196313, - 0.00994642715067187, - 0.009957457493857359, - 0.009767160156250537, - 0.009911570862240747, - 0.009901594554455355, - 0.01000816758379198 + 0.009763983406540043, + 0.009777022482893027, + 0.009758514146341643, + 0.009768362304687639, + 0.009716928120445278, + 0.009732914355230464, + 0.009724182304326071 ], - "runs": 14150, + "runs": 14364, "checksum": 619454386, "status": "OK" } @@ -240,17 +240,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.0440179758241755, + "ms_per_run": 0.04401417582417597, "samples": [ - 0.043962758241758715, - 0.04403651648351632, - 0.04393292105263207, - 0.043964909890110745, - 0.04417963134657799, - 0.0440179758241755, - 0.04417972185430466 + 0.043971690109889584, + 0.04401417582417597, + 0.0440592907488991, + 0.04405766960352186, + 0.043967314285713564, + 0.044154439293598094, + 0.04401314945054819 ], - "runs": 3182, + "runs": 3181, "checksum": 604367096, "status": "OK" }, @@ -258,17 +258,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 1000, - "ms_per_run": 0.10917846739130285, + "ms_per_run": 0.10689089361701487, "samples": [ - 0.10947957377049196, - 0.10864083243243124, - 0.10917846739130285, - 0.10792587634408808, - 0.1109336906077308, - 0.10989890659340781, - 0.10890227717390975 + 0.10631125925925865, + 0.10695356149732406, + 0.10703539037433057, + 0.10681245744680662, + 0.10689089361701487, + 0.10646244148936639, + 0.1070475508021358 ], - "runs": 1285, + "runs": 1314, "checksum": 604367096, "status": "OK" } @@ -279,17 +279,17 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 1.6902569166666648, + "ms_per_run": 1.6741655000000009, "samples": [ - 1.676083250000005, - 1.6844255000000032, - 1.6775207499999993, - 1.6902569166666648, - 1.791112333333345, - 1.8052902500000034, - 1.8203548181818094 + 1.6741655000000009, + 1.671103999999995, + 1.6763152500000065, + 1.6730892500000039, + 1.6726736666666777, + 1.6743867499999965, + 1.6742046666666681 ], - "runs": 83, + "runs": 84, "checksum": 37547811, "status": "OK" }, @@ -297,15 +297,15 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 10000, - "ms_per_run": 2.386657888888888, + "ms_per_run": 2.3649916666666715, "samples": [ - 2.39644633333333, - 2.4205858888888847, - 2.3792675555555527, - 2.399434111111096, - 2.3732842222222086, - 2.386657888888888, - 2.3856461111111003 + 2.3636828888888886, + 2.380627666666672, + 2.359443777777768, + 2.369723999999994, + 2.375694111111102, + 2.356767666666675, + 2.3649916666666715 ], "runs": 63, "checksum": 37547811, @@ -318,15 +318,15 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100000, - "ms_per_run": 206.08473400000003, + "ms_per_run": 206.45521999999983, "samples": [ - 305.05159000000003, - 405.7424560000002, - 160.37497400000007, - 162.81490899999994, - 161.91186300000027, - 206.08473400000003, - 217.9855480000001 + 252.36218399999984, + 338.75615200000016, + 159.05078099999992, + 159.331956, + 159.50258499999995, + 206.45521999999983, + 215.29910100000006 ], "runs": 7, "checksum": 275047240, @@ -336,15 +336,15 @@ "name": "array-splice-middle-insert", "category": "arrays", "n": 100000, - "ms_per_run": 167.87403699999982, + "ms_per_run": 165.80140400000005, "samples": [ - 169.553904, - 165.93865300000004, - 165.74730499999987, - 167.87403699999982, - 170.0324280000002, - 170.2465169999998, - 167.04205299999967 + 165.65149500000007, + 165.37159099999997, + 165.80991999999992, + 165.80140400000005, + 165.94846800000005, + 165.87510099999986, + 165.4396290000002 ], "runs": 7, "checksum": 275047240, @@ -359,8 +359,8 @@ 100000 ], "common_slopes": { - "node": 1.5953493712731313, - "perry": 1.4026176309069263 + "node": 1.5950658308870878, + "perry": 1.4035502430328335 }, "acceptance_sizes": [ 1000, @@ -368,10 +368,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.8352078772385179, - "perry": 1.5934232704187608 + "node": 1.8356166470102018, + "perry": 1.595323747997074 }, - "acceptance_slope_delta": -0.2417846068197571 + "acceptance_slope_delta": -0.24029289901312767 }, "array-unshift-build": { "rows": [ @@ -381,17 +381,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.0033944065840831976, + "ms_per_run": 0.0033775864572776174, "samples": [ - 0.0034049019407556653, - 0.003425276588457152, - 0.003409461905572877, - 0.003391798880786283, - 0.0033944065840831976, - 0.00338697595258245, - 0.0033825758498225982 + 0.0033740381241561795, + 0.0033775864572776174, + 0.003614731429603682, + 0.0034307938604013397, + 0.003363807265388638, + 0.0033633991928705286, + 0.003377586963863733 ], - "runs": 41188, + "runs": 41029, "checksum": 922626605, "status": "OK" }, @@ -399,17 +399,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 100, - "ms_per_run": 0.004971755406413041, + "ms_per_run": 0.0049986651674162365, "samples": [ - 0.004971755406413041, - 0.005210262047408393, - 0.004958451412989683, - 0.00521068637666096, - 0.004961171626984339, - 0.005391944303798188, - 0.004961890349789468 + 0.004988065087281702, + 0.005792555169417779, + 0.0049986651674162365, + 0.004974666500870215, + 0.005874302202643309, + 0.004969518757763908, + 0.005762233074041185 ], - "runs": 27748, + "runs": 26387, "checksum": 922626605, "status": "OK" } @@ -420,17 +420,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.05574202506963839, + "ms_per_run": 0.05683482102272757, "samples": [ - 0.05575101949860721, - 0.055771139275767524, - 0.05574202506963839, - 0.055686266666669974, - 0.055720356545961434, - 0.055704075000001296, - 0.05574750696378669 + 0.057129615384615445, + 0.05712500569800549, + 0.05734376790831055, + 0.05683482102272757, + 0.05671412464589394, + 0.056604999999998976, + 0.056568836158191325 ], - "runs": 2515, + "runs": 2464, "checksum": 414934349, "status": "OK" }, @@ -438,17 +438,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 1000, - "ms_per_run": 0.0716535821428593, + "ms_per_run": 0.072260523465705, "samples": [ - 0.07162994642857134, - 0.07296168000000151, - 0.0716535821428593, - 0.07190240860215252, - 0.07156488928571321, - 0.07532254135338412, - 0.07122930960853939 + 0.07215888848920934, + 0.073547874999998, + 0.07215744244604148, + 0.07254304710144786, + 0.07214932374100771, + 0.07347654212454112, + 0.072260523465705 ], - "runs": 1941, + "runs": 1932, "checksum": 414934349, "status": "OK" } @@ -459,17 +459,17 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 3.317559571428571, + "ms_per_run": 3.3754080000000215, "samples": [ - 3.317559571428571, - 3.4456023333333214, - 3.7074698333333345, - 3.3151864285714265, - 3.312718999999999, - 3.31008300000002, - 3.3196221428571397 + 3.404167166666672, + 3.3930141666666693, + 3.3754080000000215, + 3.377228333333344, + 3.3626045000000033, + 3.372030333333337, + 3.370495333333338 ], - "runs": 47, + "runs": 42, "checksum": 183209813, "status": "OK" }, @@ -477,15 +477,15 @@ "name": "array-unshift-build", "category": "arrays", "n": 10000, - "ms_per_run": 3.5221938333333327, + "ms_per_run": 3.567977833333335, "samples": [ - 3.5212676666666596, - 3.5221938333333327, - 3.5471274999999927, - 3.5247338333333573, - 3.515755666666687, - 3.5455643333333264, - 3.514045500000009 + 3.5646998333333264, + 3.5661075000000104, + 3.584528666666685, + 3.5982860000000016, + 3.567977833333335, + 3.5903093333333325, + 3.5592214999999974 ], "runs": 42, "checksum": 183209813, @@ -498,15 +498,15 @@ "name": "array-unshift-build", "category": "arrays", "n": 100000, - "ms_per_run": 316.54610300000013, + "ms_per_run": 323.362564, "samples": [ - 321.4570640000002, - 312.680965, - 316.54610300000013, - 320.5322369999999, - 313.5053939999998, - 321.06873799999994, - 313.2147420000001 + 323.2890970000001, + 323.362564, + 324.18969800000013, + 322.71431399999983, + 323.78218700000025, + 322.60169399999995, + 325.9033370000002 ], "runs": 7, "checksum": 622348785, @@ -516,15 +516,15 @@ "name": "array-unshift-build", "category": "arrays", "n": 100000, - "ms_per_run": 314.45723, + "ms_per_run": 316.25168900000017, "samples": [ - 314.8736080000001, - 314.02176699999995, - 313.9972419999999, - 314.7522210000002, - 314.171507, - 314.45723, - 314.5252069999997 + 317.29108899999983, + 316.492569, + 316.1632940000004, + 316.6058000000003, + 316.25168900000017, + 315.88338099999964, + 316.20965099999967 ], "runs": 7, "checksum": 622348785, @@ -539,8 +539,8 @@ 100000 ], "common_slopes": { - "node": 1.6683655318093131, - "perry": 1.6094730835042874 + "node": 1.6716961574139262, + "perry": 1.6097057464790885 }, "acceptance_sizes": [ 1000, @@ -548,10 +548,10 @@ 100000 ], "acceptance_slopes": { - "node": 1.8771271145022215, - "perry": 1.8211618388636228 + "node": 1.877537621539156, + "perry": 1.820565875141814 }, - "acceptance_slope_delta": -0.055965275638598655 + "acceptance_slope_delta": -0.05697174639734204 } } } From 17aefb8838a5a5c7e8f83c90e0bee3cf8a4642f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 17:03:02 +0200 Subject: [PATCH 20/36] chore: bump workspace version to 0.5.1540 Train167 (#10115, #10125, #10126, #10127, #10133, #10134) lands on main at 0.5.1539; none of the PRs bumped the version, which is the maintainer's job at merge time. Cargo.lock regenerated so every workspace member's inherited version moves with it. --- CLAUDE.md | 2 +- Cargo.lock | 158 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 81 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b5aa23c422..8cbfcbcfdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1539 +**Current Version:** 0.5.1540 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index bddecc9427..dfcdef31bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5695,7 +5695,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "base64 0.22.1", @@ -5759,7 +5759,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-dispatch", "serde", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "cc", "libc", @@ -5776,7 +5776,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "aho-corasick", "anyhow", @@ -5794,7 +5794,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-hir", @@ -5802,7 +5802,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-hir", @@ -5810,7 +5810,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-dispatch", @@ -5819,7 +5819,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-hir", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "base64 0.22.1", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-hir", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "async-trait", @@ -5876,14 +5876,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "serde", "serde_json", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1539" +version = "0.5.1540" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5902,7 +5902,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "clap", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "block2", "objc2", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "argon2", "perry-ffi", @@ -5936,7 +5936,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "reqwest", @@ -5945,7 +5945,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "bcrypt", "perry-ffi", @@ -5953,7 +5953,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "rusqlite", @@ -5961,7 +5961,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "scraper", @@ -5969,7 +5969,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "perry-runtime", @@ -5977,7 +5977,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "chrono", "cron", @@ -5987,7 +5987,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "chrono", "perry-ffi", @@ -5995,7 +5995,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "rust_decimal", @@ -6003,7 +6003,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "serde_json", @@ -6011,7 +6011,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6019,7 +6019,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "perry-runtime", @@ -6027,14 +6027,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "bytes", "http-body-util", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "bytes", "lazy_static", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "bytes", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "lazy_static", "perry-ffi", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6118,7 +6118,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "lru", "perry-ffi", @@ -6127,7 +6127,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "chrono", "perry-ffi", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "bson", "futures-util", @@ -6147,7 +6147,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "chrono", "perry-ffi", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "nanoid", "perry-ffi", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "bytes", "perry-ffi", @@ -6183,7 +6183,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6202,7 +6202,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "lettre", "perry-ffi", @@ -6212,7 +6212,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "fancy-regex", "notify", @@ -6224,7 +6224,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "printpdf", @@ -6232,7 +6232,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "sqlx", @@ -6241,7 +6241,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "perry-runtime", @@ -6250,7 +6250,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "governor", "perry-ffi", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "fast_image_resize", "image", @@ -6269,7 +6269,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "lazy_static", "perry-ffi", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-ffi", @@ -6298,7 +6298,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "perry-runtime", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "uuid", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "regex", @@ -6325,7 +6325,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "futures-util", "lazy_static", @@ -6338,7 +6338,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "brotli", "flate2", @@ -6348,7 +6348,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6358,7 +6358,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-api-manifest", @@ -6377,11 +6377,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1539" +version = "0.5.1540" [[package]] name = "perry-parser" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-diagnostics", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "ahash", "anyhow", @@ -6457,14 +6457,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1539" +version = "0.5.1540" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1539" +version = "0.5.1540" [[package]] name = "perry-ui-tvos" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1539" +version = "0.5.1540" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 842285bb80..81b509c61d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -336,7 +336,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1539" +version = "0.5.1540" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" From cca8ecf1bcc68752c56b36ff50c49897c07ee842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 17:04:47 +0200 Subject: [PATCH 21/36] chore(gc): classify #10127's BODY_RECORD_LOOKUPS holder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gc_runtime_root_holders` failed on the train: the call/apply rest-dispatch cache adds a new identity-ratcheted thread-local that nothing classifies. crates/perry-runtime/src/closure/registry.rs:312: BODY_RECORD_LOOKUPS: std::cell::Cell [rule T] It is a counter inside a `#[cfg(test)]` `std::thread_local!` block — absent from production builds, holding a `u32` count and never an address — so it takes a researched `test_only` verdict. Its sibling in the same block, RESOLVE_STRATEGY_SLOW_CALLS, is pinned on the frontier as debt. A pinned entry is explicitly not a GC-safety verdict, so the new one is classified rather than pinned; that is the stronger record and it keeps the frontier list from growing for a value that is trivially provable. --- scripts/gc_runtime_root_holders.json | 90 +++++++++++++++------------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index fa68dfa74e..fe1e3d4063 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -33,12 +33,6 @@ "Known census holders have explicit verdicts, including PASS1_MARKED's window contract." ], "holders": [ - { - "file": "crates/perry-runtime/src/weakref/test_support.rs", - "name": "WEAK_ENTRY_VISITS", - "verdict": "test_only", - "why": "Entry-array visit counter for the WeakMap operation-scaling regression; contains only a usize count and the parent module is cfg(test)." - }, { "file": "crates/perry-ext-exponential-backoff/src/lib.rs", "name": "NEXT_ID", @@ -255,6 +249,12 @@ "verdict": "not_a_gc_pointer", "why": "#9976: a `Cell` test seam that suppresses the closure young-log note so a test can exercise the full-walk fallback. A FLAG; there is no slot for the collector." }, + { + "file": "crates/perry-runtime/src/closure/registry.rs", + "name": "BODY_RECORD_LOOKUPS", + "verdict": "test_only", + "why": "#10127's call/apply rest-dispatch cache counts how often a dispatch resolves through the body-record lookup instead of the cache, so the tests can assert the cache is actually taken rather than that nothing threw. It is a `Cell` count inside a `#[cfg(test)]` `std::thread_local!` block — absent from production builds, and it never stores an address. Its sibling RESOLVE_STRATEGY_SLOW_CALLS in the same block is pinned on the frontier as debt; this one takes a researched verdict instead, which is the stronger record." + }, { "file": "crates/perry-runtime/src/closure/registry.rs", "name": "CLOSURE_BODY_REGISTRY", @@ -396,6 +396,12 @@ "verdict": "not_a_gc_pointer", "why": "#9772: releasable block BYTES the last idle selection promised — a size, not an address. A `Cell` compared against what the collection actually released." }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_TINY_PARSE_BOUNDARY_POLL_REMAINING", + "verdict": "not_a_gc_pointer", + "why": "Tiny-JSON completion countdown in a Cell. It stores only the number of bounded parse completions before the next arena-pressure poll (0..63), never an address or a NaN-boxed value." + }, { "file": "crates/perry-runtime/src/gc/policy.rs", "name": "GC_TINY_PARSE_PRESSURE_BASE_BYTES", @@ -486,6 +492,24 @@ "verdict": "not_a_gc_pointer", "why": "#9870: `PERRY_SEGVIEW_DIAG` tally for the Intl.Segmenter view mode — how often the fast path opened, and which check declined it. A plain `AtomicU64` written only through `bump()`'s `fetch_add(1, Relaxed)` and read only by the diagnostic dump: it holds a COUNT, never an address or a NaN-boxed value, so there is no slot for the collector to mark or rewrite." }, + { + "file": "crates/perry-runtime/src/json/mod.rs", + "name": "PARSE_KEY_CACHE_OVERSIZED", + "verdict": "not_a_gc_pointer", + "why": "Boolean latch recording whether this thread's JSON.parse key cache exceeded its bounded entry count. It stores only true/false; the process-wide companion is an AtomicUsize count, and neither value can contain a GC address or NaN-boxed value." + }, + { + "file": "crates/perry-runtime/src/json/parse_empty.rs", + "name": "EMPTY_JSON_SHAPE_ID", + "verdict": "not_a_gc_pointer", + "why": "Per-agent runtime ShapeId for the immutable keyless ordinary-object descriptor. A u32 slab identifier copied into ObjectHeader.parent_class_id; it is never interpreted or dereferenced as a heap address, and keyless descriptors contain no movable keys edge." + }, + { + "file": "crates/perry-runtime/src/json/stringify_flat.rs", + "name": "JSON_OUTPUT_BYTES_SINCE_SWEEP", + "verdict": "not_a_gc_pointer", + "why": "Cell holds two usize byte counts: completed output bytes and a deferred boundary cutoff. They are added, compared with a byte budget, and subtracted on acknowledgement. Neither is derived from an address or dereferenced; the state contains no managed value or pointer." + }, { "file": "crates/perry-runtime/src/json/stringify_record_output.rs", "name": "KEY_PREFIX_CACHE", @@ -680,18 +704,18 @@ "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." }, - { - "file": "crates/perry-runtime/src/object/regex_proto_thunks.rs", - "name": "REGEXP_PROTOTYPE_TEST_WALKS", - "verdict": "not_a_gc_pointer", - "why": "View-mode diagnostic tally of install-time RegExp.prototype.test walks. A plain AtomicU64 incremented once by record_canonical_test_site and read as a count by tests; it never stores an address or NaN-boxed value. The actual prototype and closure roots live together in REGEXP_PROTOTYPE_TEST_SITE and are visited by scan_canonical_test_site_roots_mut." - }, { "file": "crates/perry-runtime/src/object/read_stub.rs", "name": "READ_STUB", "verdict": "not_a_gc_pointer", "why": "Megamorphic property-read stub cache, the read twin of WRITE_STUB: 2-way ways of (shape_token, key_bits, slot) plain u64s. The token is a shape id and the slot an index; key_bits are content-derived by construction, because read_stub_key_bits returns short_ascii_sso_bits(key) — the key's characters packed inline — and yields None for any key that would otherwise be stored under a pointer. No way holds a heap address, so nothing here keeps an object alive, and a stale entry cannot hit: receiver_shape_token returns None for a receiver with no live shape, and the token identifies the exact key set and order, so a shape change yields a different token." }, + { + "file": "crates/perry-runtime/src/object/regex_proto_thunks.rs", + "name": "REGEXP_PROTOTYPE_TEST_WALKS", + "verdict": "not_a_gc_pointer", + "why": "View-mode diagnostic tally of install-time RegExp.prototype.test walks. A plain AtomicU64 incremented once by record_canonical_test_site and read as a count by tests; it never stores an address or NaN-boxed value. The actual prototype and closure roots live together in REGEXP_PROTOTYPE_TEST_SITE and are visited by scan_canonical_test_site_roots_mut." + }, { "file": "crates/perry-runtime/src/object/shapes.rs", "name": "SHAPE_YOUNG_LOG_SUPPRESSED", @@ -867,6 +891,12 @@ "verdict": "not_a_gc_pointer", "why": "Derived cache of the Set's own elements array, which is the canonical scanned storage (GcRewriteDescriptorKind::Set element-slot walk). Not root-scanner-shaped: the outer address key is rekeyed on relocation by GcMoveHookKind::SetSideTables -> set_header_moved_for_gc (set.rs:352), the JSValueKey inner keys are rebuilt post-rewrite by GcRewriteHookKind::SetIndex -> rebuild_set_index_for_gc (set.rs:315, fired from gc/copying.rs:669/798, gc/barrier/mod.rs:195, gc/verify.rs:114), and finalize_set_side_allocation_for_gc (set.rs:374) prunes dead owners — the same three-hook design as MAP_INDEX." }, + { + "file": "crates/perry-runtime/src/string/char_ops/utf16_index.rs", + "name": "DECODE_STEPS", + "verdict": "test_only", + "why": "A cfg(test) per-thread integer count of bounded decoder calls, used to assert linear work through the public character-access API. It never stores a heap address." + }, { "file": "crates/perry-runtime/src/string/concat.rs", "name": "CONCAT_MEMO_TAGS", @@ -909,6 +939,12 @@ "verdict": "not_a_gc_pointer", "why": "Maps scalar timer handle IDs to the CallbackTimerKind enum so clearTimeout/clearInterval can destroy the matching async_hooks resource. Neither the i64 keys nor the enum values contain a JS heap address." }, + { + "file": "crates/perry-runtime/src/weakref/test_support.rs", + "name": "WEAK_ENTRY_VISITS", + "verdict": "test_only", + "why": "Entry-array visit counter for the WeakMap operation-scaling regression; contains only a usize count and the parent module is cfg(test)." + }, { "file": "crates/perry-runtime/src/webassembly.rs", "name": "WASM_NEXT_IMPORT_TOKEN", @@ -2309,36 +2345,6 @@ "name": "WINDOW_ROOTS", "verdict": "not_a_gc_pointer", "why": "Window-root registry maps numeric window handles to numeric root-widget handles; neither value is a JavaScript heap pointer." - }, - { - "file": "crates/perry-runtime/src/gc/policy.rs", - "name": "GC_TINY_PARSE_BOUNDARY_POLL_REMAINING", - "verdict": "not_a_gc_pointer", - "why": "Tiny-JSON completion countdown in a Cell. It stores only the number of bounded parse completions before the next arena-pressure poll (0..63), never an address or a NaN-boxed value." - }, - { - "file": "crates/perry-runtime/src/json/mod.rs", - "name": "PARSE_KEY_CACHE_OVERSIZED", - "verdict": "not_a_gc_pointer", - "why": "Boolean latch recording whether this thread's JSON.parse key cache exceeded its bounded entry count. It stores only true/false; the process-wide companion is an AtomicUsize count, and neither value can contain a GC address or NaN-boxed value." - }, - { - "file": "crates/perry-runtime/src/json/parse_empty.rs", - "name": "EMPTY_JSON_SHAPE_ID", - "verdict": "not_a_gc_pointer", - "why": "Per-agent runtime ShapeId for the immutable keyless ordinary-object descriptor. A u32 slab identifier copied into ObjectHeader.parent_class_id; it is never interpreted or dereferenced as a heap address, and keyless descriptors contain no movable keys edge." - }, - { - "file": "crates/perry-runtime/src/json/stringify_flat.rs", - "name": "JSON_OUTPUT_BYTES_SINCE_SWEEP", - "verdict": "not_a_gc_pointer", - "why": "Cell holds two usize byte counts: completed output bytes and a deferred boundary cutoff. They are added, compared with a byte budget, and subtracted on acknowledgement. Neither is derived from an address or dereferenced; the state contains no managed value or pointer." - }, - { - "file": "crates/perry-runtime/src/string/char_ops/utf16_index.rs", - "name": "DECODE_STEPS", - "verdict": "test_only", - "why": "A cfg(test) per-thread integer count of bounded decoder calls, used to assert linear work through the public character-access API. It never stores a heap address." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, “The identity-pinned frontier”). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", From c47604c56a8779653c75cddf14c695a95c3fd5be Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:34:02 +0200 Subject: [PATCH 22/36] fix(runtime): explain native dynamic import limitations (cherry picked from commit 766d1f2d80f87099a0c756c24afd6e975d3aacab) --- changelog.d/10105-native-runtime-imports.md | 10 ++ crates/perry-runtime/src/module_require.rs | 25 ++- .../module_require/dynamic_import_tests.rs | 91 +++++++++++ .../issue_10105_native_runtime_plugins.rs | 147 ++++++++++++++++++ docs/src/language/limitations.md | 34 ++++ 5 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 changelog.d/10105-native-runtime-imports.md create mode 100644 crates/perry-runtime/src/module_require/dynamic_import_tests.rs create mode 100644 crates/perry/tests/issue_10105_native_runtime_plugins.rs diff --git a/changelog.d/10105-native-runtime-imports.md b/changelog.d/10105-native-runtime-imports.md new file mode 100644 index 0000000000..9ca17c7d3a --- /dev/null +++ b/changelog.d/10105-native-runtime-imports.md @@ -0,0 +1,10 @@ +Runtime `import()` failures now name the requested module and explain the native +build's runtime-JavaScript limitation, with guidance to use the application's +Bun/Node distribution or compile the module through a statically resolvable +import. Deferred imports keep their source location and all failures retain +`ERR_MODULE_NOT_FOUND` for optional-dependency handlers. Supported builtin and +compiled imports continue to resolve. + +Documents option A for the initial native OpenCode deliverable (#10105, #10107) +and adds runtime diagnostics tests plus a minimized plugin-loader regression +covering one report and continued startup after a configured plugin fails. diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index e5551bece7..fa909d7924 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -12,6 +12,9 @@ use crate::object::{js_object_alloc, js_object_get_field_by_name, js_object_set_ use crate::string::js_string_from_bytes; use crate::value::{js_nanbox_pointer, JSValue, TAG_FALSE, TAG_NULL, TAG_TRUE, TAG_UNDEFINED}; +#[cfg(test)] +mod dynamic_import_tests; + fn undefined() -> f64 { f64::from_bits(TAG_UNDEFINED) } @@ -1244,7 +1247,21 @@ fn dynamic_import_fallback_promise(spec: f64, deferred_note: Option) -> let promise = crate::promise::js_promise_resolved(namespace); return js_nanbox_pointer(promise as i64); } - let message = deferred_note.unwrap_or_else(|| format!("Cannot find module '{spec_str}'")); + // #10105: this is an AOT boundary, even when the package exists on disk. + // Keep the conventional error code for optional-dependency handlers, but + // give callers that surface error.message an actionable explanation. Do + // not also log here: plugin loaders own reporting and startup recovery. + let mut message = format!( + "Cannot find module '{spec_str}': loading JavaScript modules at runtime is \ + not available in this native build (including runtime plugins, custom \ + tools, and non-bundled providers). Use the application's Bun/Node \ + distribution, or compile the module into the binary through a \ + statically resolvable import()." + ); + if let Some(note) = deferred_note { + message.push(' '); + message.push_str(¬e); + } let msg_ptr = js_string_from_bytes(message.as_ptr(), message.len() as u32); crate::node_submodules::register_error_code_pub(msg_ptr, "ERR_MODULE_NOT_FOUND"); let err = crate::error::js_error_new_with_message(msg_ptr); @@ -1318,9 +1335,9 @@ static KEEP_JS_MODULE_DYNAMIC_IMPORT_FALLBACK: extern "C" fn(f64) -> f64 = /// Codegen entry for #5230 *deferred* dynamic-import sites (runtime-computed /// specifier under the default non-strict policy). Same builtin-or-reject -/// fallback, but a genuinely unknown module rejects with the compile-time -/// deferral message (which names the site's `file:line`) instead of the -/// generic `Cannot find module` text. `msg` is the NaN-boxed deferral string. +/// fallback, adding the compile-time deferral message (which names the site's +/// `file:line`) to the requested module and native-build guidance. `msg` is the +/// NaN-boxed deferral string. #[no_mangle] pub extern "C" fn js_module_dynamic_import_deferred(spec: f64, msg: f64) -> f64 { let note = { diff --git a/crates/perry-runtime/src/module_require/dynamic_import_tests.rs b/crates/perry-runtime/src/module_require/dynamic_import_tests.rs new file mode 100644 index 0000000000..49f5b3a5a9 --- /dev/null +++ b/crates/perry-runtime/src/module_require/dynamic_import_tests.rs @@ -0,0 +1,91 @@ +//! #10105: callers can report the native module-loading boundary and recover. +use super::*; +use crate::promise::{js_promise_reason, js_promise_state, Promise}; + +fn rejection_message(value: f64) -> String { + assert_ne!(crate::promise::js_value_is_promise(value), 0); + let promise = crate::value::js_nanbox_get_pointer(value) as *mut Promise; + assert_eq!( + js_promise_state(promise), + 2, + "import must reject asynchronously" + ); + crate::promise::js_promise_mark_internally_handled(promise); + let error = crate::value::js_nanbox_get_pointer(js_promise_reason(promise)) + as *mut crate::error::ErrorHeader; + assert_eq!( + crate::node_submodules::error_code_for_error(error).as_deref(), + Some("ERR_MODULE_NOT_FOUND"), + "keep the code used by optional dependency loaders" + ); + unsafe { + assert_eq!( + crate::exception::string_header_to_string(crate::error::js_error_get_name(error)), + "Error" + ); + crate::exception::string_header_to_string(crate::error::js_error_get_message(error)) + } +} + +fn assert_native_guidance(message: &str, specifier: &str) { + for expected in [ + specifier, + "not available in this native build", + "plugins", + "custom tools", + "providers", + "Bun/Node distribution", + "statically resolvable import()", + ] { + assert!( + message.contains(expected), + "missing {expected:?}: {message}" + ); + } +} + +#[test] +fn unresolved_imports_name_the_module_and_native_build_remedy() { + for specifier in [ + "some-npm-plugin", + "file:///config/tools/custom.ts", + "file:///config/plugins/tui.tsx", + "@example/custom-provider", + ] { + let message = rejection_message(js_module_dynamic_import_fallback(string_value(specifier))); + assert_native_guidance(&message, specifier); + } +} + +#[test] +fn deferred_import_keeps_the_site_and_adds_the_runtime_specifier() { + let scope = crate::gc::RuntimeHandleScope::new(); + let specifier = scope.root_nanbox_f64(string_value("some-npm-plugin")); + let note = string_value( + "dynamic import() of a runtime-computed path cannot run in an ahead-of-time compiled binary (src/plugin/loader.ts:139)", + ); + let message = rejection_message(js_module_dynamic_import_deferred( + specifier.get_nanbox_f64(), + note, + )); + assert_native_guidance(&message, "some-npm-plugin"); + assert_eq!(message.matches("src/plugin/loader.ts:139").count(), 1); +} + +#[test] +fn deferred_builtin_imports_still_resolve() { + for specifier in ["os", "node:os", "node:fs/promises"] { + let scope = crate::gc::RuntimeHandleScope::new(); + let specifier = scope.root_nanbox_f64(string_value(specifier)); + let note = string_value("deferred import at src/plugin/loader.ts:139"); + let value = js_module_dynamic_import_deferred(specifier.get_nanbox_f64(), note); + assert_ne!(crate::promise::js_value_is_promise(value), 0); + let promise = crate::value::js_nanbox_get_pointer(value) as *mut Promise; + assert_eq!(js_promise_state(promise), 1, "builtin import must resolve"); + assert_ne!( + crate::promise::js_promise_value(promise).to_bits(), + TAG_UNDEFINED, + "the resolved namespace must exist" + ); + } +} diff --git a/crates/perry/tests/issue_10105_native_runtime_plugins.rs b/crates/perry/tests/issue_10105_native_runtime_plugins.rs new file mode 100644 index 0000000000..769f3d168c --- /dev/null +++ b/crates/perry/tests/issue_10105_native_runtime_plugins.rs @@ -0,0 +1,147 @@ +//! #10105: minimized OpenCode PluginLoader.load/report/startup contract. +//! This exercises compiled TypeScript recovery, not the full OpenTUI renderer. +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn configured_runtime_plugin_reports_once_and_startup_continues() { + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let perry = PathBuf::from(env!("CARGO_BIN_EXE_perry")); + let runtime_dir = perry.parent().expect("compiler directory"); + let mut build = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); + build.current_dir(&workspace).args([ + "build", + "--locked", + "-p", + "perry", + "-p", + "perry-runtime-static", + "-p", + "perry-stdlib-static", + ]); + // Match the outer cargo invocation's profile, including --release and + // --profile perry-dev; never silently link archives from another build. + let profile = runtime_dir.file_name().unwrap().to_str().unwrap(); + if profile != "debug" { + build.args(["--profile", profile]); + } + let build = build.output().expect("build compiler and static archives"); + assert!( + build.status.success(), + "static archive build failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + + let dir = tempfile::tempdir().expect("fixture directory"); + let entry = dir.path().join("main.ts"); + std::fs::write( + dir.path().join("bundled.ts"), + "export const ready = 'bundled provider ready';\n", + ) + .unwrap(); + std::fs::write( + &entry, + r#" +// Mirrors PluginLoader.load's catch-and-return and the caller's single report. +async function load(row: any): Promise { + try { + const mod = await import(row.entry); + return { ok: true, mod }; + } catch (error) { + return { ok: false, error }; + } +} + +const config = JSON.parse(process.argv[2]); +for (const spec of config.plugin) { + const result = await load({ entry: spec }); + if (!result.ok) { + console.error('[tui.plugin] failed to load tui plugin: ' + result.error.message); + console.log('plugin error code: ' + result.error.code); + } else { + console.log('unexpected plugin success'); + } +} + +// The same computed import site still accepts native builtins after a rejection. +const builtin = await load({ entry: 'node:os' }); +console.log('builtin ready: ' + builtin.ok); +const bundled = await import('./bundled.ts'); +console.log(bundled.ready); +console.log('startup continued'); +"#, + ) + .unwrap(); + let executable = dir + .path() + .join(if cfg!(windows) { "main.exe" } else { "main" }); + let compiled = Command::new(&perry) + .current_dir(dir.path()) + .args(["compile", "--no-cache"]) + .arg(&entry) + .arg("-o") + .arg(&executable) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir) + .output() + .expect("compile fixture"); + assert!( + compiled.status.success(), + "compile failed: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + + // Even an installed plugin is outside the AOT graph when added after + // compilation. The diagnostic must not suggest another npm install. + let package_dir = dir.path().join("node_modules/some-npm-plugin"); + std::fs::create_dir_all(&package_dir).unwrap(); + std::fs::write( + package_dir.join("package.json"), + r#"{"name":"some-npm-plugin","type":"module","main":"index.js"}"#, + ) + .unwrap(); + std::fs::write( + package_dir.join("index.js"), + "export const plugin = true;\n", + ) + .unwrap(); + + let run = Command::new(&executable) + .current_dir(dir.path()) + .arg(r#"{"plugin":["some-npm-plugin"]}"#) + .output() + .expect("run fixture"); + let stderr = String::from_utf8_lossy(&run.stderr); + assert!(run.status.success(), "startup failed: {stderr}"); + assert_eq!( + String::from_utf8_lossy(&run.stdout).replace("\r\n", "\n"), + "plugin error code: ERR_MODULE_NOT_FOUND\nbuiltin ready: true\nbundled provider ready\nstartup continued\n" + ); + assert_eq!( + stderr.lines().count(), + 1, + "one application report: {stderr}" + ); + for expected in [ + "[tui.plugin] failed to load tui plugin:", + "some-npm-plugin", + "not available in this native build", + "Bun/Node distribution", + "statically resolvable import()", + "main.ts:", + ] { + assert!(stderr.contains(expected), "missing {expected:?}: {stderr}"); + } + + let without_plugins = Command::new(&executable) + .current_dir(dir.path()) + .arg(r#"{"plugin":[]}"#) + .output() + .expect("run without plugins"); + assert!(without_plugins.status.success()); + assert!(without_plugins.stderr.is_empty()); + assert_eq!( + String::from_utf8_lossy(&without_plugins.stdout).replace("\r\n", "\n"), + "builtin ready: true\nbundled provider ready\nstartup continued\n" + ); +} diff --git a/docs/src/language/limitations.md b/docs/src/language/limitations.md index 902ed31cdc..e6bf02a474 100644 --- a/docs/src/language/limitations.md +++ b/docs/src/language/limitations.md @@ -63,6 +63,15 @@ listed in the shared notice above under the `import(...)` kind, and does **not** abort the build. This lets an app with a plugin-loader path compile and run its core, with only the plugin-load path throwing if exercised. +If the runtime specifier names a supported Node builtin, Perry resolves its +native namespace. Otherwise, when it has no compiled target, the rejected +`Error` retains `code: "ERR_MODULE_NOT_FOUND"` for existing optional-dependency +handlers. Its message names the requested module, explains that runtime-loaded +JavaScript is unavailable in the native build, and suggests the application's +Bun/Node distribution or a statically resolvable import followed by recompilation. +Deferred sites also retain their source location. Perry does not print a second +runtime warning: the application's catch/report path owns the diagnostic. + Resolvable specifiers are unaffected and still compile + load: string literals (`import("./mod.js")`), ternaries of resolvable arms, template literals over `const` locals (`` import(`./${KIND}.js`) ``), finite string-literal-union @@ -78,6 +87,31 @@ async function loadPlugin(name: string) { } ``` +#### Native OpenCode plugins (#10105) + +The first native OpenCode deliverable follows **option A** from +[#10105](https://github.com/PerryTS/perry/issues/10105), tracked in +[#10107](https://github.com/PerryTS/perry/issues/10107): runtime-installed npm +plugins, local JS/TS/TSX plugins (including TUI plugins), custom tools, and provider +SDKs absent from the compiled graph are unavailable. Installing a package after +compilation does not add its code to the executable. Bundled providers reached +through static imports remain supported. + +Use the Bun distribution when those extensions are required. Including extensions +in a statically reachable import graph and recompiling is a build-time option; +there is currently no automatic plugin-pack install/recompile command. A whole +ESM/CJS module interpreter (option B) and plugin-pack compilation/loading (option C) +are deferred decisions, not capabilities enabled by the existing dynamic-eval +interpreter. Runtime data imports such as TOML are a separate feature (#10104). + +OpenCode v1.18.30's `PluginLoader.load` catches import rejections, and its caller +reports a load failure without retrying that stage. The TUI plugin reporter +includes `error.message`, so it can explain the native limitation and continue +startup. Perry's regression test covers that minimized loader/report/startup +contract with `plugin: ["some-npm-plugin"]`, one report, and successful builtin and +bundled imports afterward. Full OpenTUI rendering remains part of the integration +acceptance in #10107. + ### Strict mode: refuse at compile time To make every runtime-unknown site a hard compile-time error instead, opt into From b9bba458a3ccc016b6d68456ef090556f9057e3d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:35:19 +0200 Subject: [PATCH 23/36] chore: key runtime import changeset to PR 10131 (cherry picked from commit eedb303956ac2f227c20627d0cee782db97b6aa9) --- ...-native-runtime-imports.md => 10131-native-runtime-imports.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10105-native-runtime-imports.md => 10131-native-runtime-imports.md} (100%) diff --git a/changelog.d/10105-native-runtime-imports.md b/changelog.d/10131-native-runtime-imports.md similarity index 100% rename from changelog.d/10105-native-runtime-imports.md rename to changelog.d/10131-native-runtime-imports.md From ae5b16ef7032b40a70c9f9d64af1b9d3077bf96e Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:46:52 +0200 Subject: [PATCH 24/36] test(runtime): use supported Windows GC path for plugin recovery (cherry picked from commit 8e2132697fa57760b764168297a96acd5b8ff690) --- .../tests/issue_10105_native_runtime_plugins.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/perry/tests/issue_10105_native_runtime_plugins.rs b/crates/perry/tests/issue_10105_native_runtime_plugins.rs index 769f3d168c..7e5d47144e 100644 --- a/crates/perry/tests/issue_10105_native_runtime_plugins.rs +++ b/crates/perry/tests/issue_10105_native_runtime_plugins.rs @@ -75,16 +75,22 @@ console.log('startup continued'); let executable = dir .path() .join(if cfg!(windows) { "main.exe" } else { "main" }); - let compiled = Command::new(&perry) + let mut compile = Command::new(&perry); + compile .current_dir(dir.path()) .args(["compile", "--no-cache"]) .arg(&entry) .arg("-o") .arg(&executable) .env("PERRY_NO_AUTO_OPTIMIZE", "1") - .env("PERRY_RUNTIME_DIR", runtime_dir) - .output() - .expect("compile fixture"); + .env("PERRY_RUNTIME_DIR", runtime_dir); + // #7354: LLVM's statepoint pass cannot process Windows catchpad EH. + // Exercise the supported shadow-frame path there; keep other hosts' GC + // defaults so this does not conceal an import/recovery regression. + if cfg!(windows) { + compile.env("PERRY_RS4GC", "0"); + } + let compiled = compile.output().expect("compile fixture"); assert!( compiled.status.success(), "compile failed: {}", From 1da089206a4c9c6d1228cd2a920fb07b53215515 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 14:32:57 +0200 Subject: [PATCH 25/36] fix(runtime): honor data loader attributes in dynamic imports (cherry picked from commit 04406e825090348912ee90f6fb76d3f4f02f990f) --- changelog.d/10104-runtime-data-imports.md | 9 + .../perry-codegen/src/expr/dyn_extern_i18n.rs | 359 +++++++++--------- .../src/runtime_decls/strings.rs | 8 +- crates/perry-hir/src/dynamic_import/tests.rs | 18 +- .../perry-hir/src/dynamic_import/visitors.rs | 10 +- crates/perry-hir/src/ir/expr.rs | 2 + .../src/lower/expr_call/intrinsics/require.rs | 2 + crates/perry-hir/src/lower/expr_call/mod.rs | 3 +- crates/perry-hir/src/stable_hash/expr.rs | 2 +- crates/perry-hir/src/walker/expr_mut.rs | 7 +- crates/perry-hir/src/walker/expr_ref.rs | 7 +- .../perry-runtime/src/bun_compat/cli_utils.rs | 31 +- crates/perry-runtime/src/module_require.rs | 33 +- .../src/module_require/data_import.rs | 98 +++++ .../src/module_require/data_import/tests.rs | 157 ++++++++ crates/perry-transform/src/inline/mod.rs | 1 + .../src/commands/compile/collect_modules.rs | 32 ++ .../compile/optimized_libs/freshness.rs | 8 +- .../commands/compile/optimized_libs/tests.rs | 16 + crates/perry/src/commands/compile/types.rs | 3 + .../tests/issue_10104_runtime_data_imports.rs | 109 ++++++ docs/src/language/limitations.md | 25 +- test-files/test_dynamic_import_data_10104.ts | 62 +++ 23 files changed, 800 insertions(+), 202 deletions(-) create mode 100644 changelog.d/10104-runtime-data-imports.md create mode 100644 crates/perry-runtime/src/module_require/data_import.rs create mode 100644 crates/perry-runtime/src/module_require/data_import/tests.rs create mode 100644 crates/perry/tests/issue_10104_runtime_data_imports.rs create mode 100644 test-files/test_dynamic_import_data_10104.ts diff --git a/changelog.d/10104-runtime-data-imports.md b/changelog.d/10104-runtime-data-imports.md new file mode 100644 index 0000000000..a757cfb5a1 --- /dev/null +++ b/changelog.d/10104-runtime-data-imports.md @@ -0,0 +1,9 @@ +Fix dynamic imports of runtime data-file paths with `with: { type }` attributes +(#10104). Absolute paths and `file://` URLs support TOML, JSON, text, and file +loaders and return a namespace with a `default` export. Invalid TOML or JSON +rejects with `SyntaxError`; runtime code modules keep their deferred error. + +Preserve import options through HIR traversal, closure/async transforms, +codegen, and cache hashing. Optimized runtimes retain the TOML parser even +without a Bun import. Regression coverage includes OpenCode's legacy TOML +configuration migration, filename URL decoding, and option evaluation order. diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 09901e3340..f6e9c4edf8 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -9,7 +9,7 @@ use perry_hir::types::Type as HirType; use perry_hir::Expr; use crate::nanbox::{double_literal, POINTER_MASK_I64}; -use crate::rooting::{with_rooted_accumulator, Arg, Repr}; +use crate::rooting::{with_rooted_accumulator, with_rooted_group, Arg, Repr}; use crate::types::{DOUBLE, I32, I64, PTR}; use super::{ @@ -566,6 +566,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::DynamicImport { paths, arg, + options, deferred_error, synchronous, .. @@ -577,191 +578,207 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if *synchronous { return lower_dynamic_require(ctx, paths, arg); } - // #5230: a non-resolvable (runtime-computed) specifier was - // *deferred* (the default, non-strict policy — analog of #5206's - // eval deferral). Evaluate the arg, then hand the runtime value to - // the deferred-fallback helper (#6660): a specifier that names a - // node BUILTIN at runtime (`imp("node:os")` through a helper the - // resolver couldn't fold) resolves to the builtin namespace like - // Node; anything else rejects with the descriptive deferral - // `Error` so `await import(spec)` throws only if this site is - // actually reached, instead of failing the whole build. - if let Some(msg) = deferred_error { - let spec_val = lower_expr(ctx, arg)?; - let msg_val = lower_expr(ctx, &Expr::String(msg.clone()))?; - return Ok(ctx.block().call( - DOUBLE, - "js_module_dynamic_import_deferred", - &[(DOUBLE, &spec_val), (DOUBLE, &msg_val)], - )); - } + with_rooted_group(ctx, 3, |ctx, roots| { + // Both arguments are evaluated once, in order. Keep the specifier + // live across option evaluation and options live across hooks/init. + let spec = roots.lower(ctx, arg, true)?; + let options = + roots.lower(ctx, options.as_deref().unwrap_or(&Expr::Undefined), true)?; + // #5230: a non-resolvable (runtime-computed) specifier was + // *deferred* (the default, non-strict policy — analog of #5206's + // eval deferral). Evaluate the arg, then hand the runtime value to + // the deferred-fallback helper (#6660): a specifier that names a + // node BUILTIN at runtime (`imp("node:os")` through a helper the + // resolver couldn't fold) resolves to the builtin namespace like + // Node; anything else rejects with the descriptive deferral + // `Error` so `await import(spec)` throws only if this site is + // actually reached, instead of failing the whole build. + if let Some(msg) = deferred_error { + let msg_val = lower_expr(ctx, &Expr::String(msg.clone()))?; + let spec_val = roots.reread(ctx, spec)?; + let options_val = roots.reread(ctx, options)?; + return Ok(ctx.block().call( + DOUBLE, + "js_module_dynamic_import_deferred", + &[ + (DOUBLE, &spec_val), + (DOUBLE, &options_val), + (DOUBLE, &msg_val), + ], + )); + } - // Defensive: an empty `paths` list means the resolver pass - // failed to populate this node, which `collect_modules` - // should have raised as a compile error. Fall through to the - // runtime fallback (#6660: builtin-or-`ERR_MODULE_NOT_FOUND` - // rejection — historically this arm rejected with literal - // `undefined`, which surfaced as a reasonless - // `Uncaught (in promise) undefined`) rather than crashing the IR. - if paths.is_empty() { - let spec_val = lower_expr(ctx, arg)?; - let hooked = ctx.block().call( + // Defensive: an empty `paths` list means the resolver pass + // failed to populate this node, which `collect_modules` + // should have raised as a compile error. Fall through to the + // runtime fallback (#6660: builtin-or-`ERR_MODULE_NOT_FOUND` + // rejection — historically this arm rejected with literal + // `undefined`, which surfaced as a reasonless + // `Uncaught (in promise) undefined`) rather than crashing the IR. + if paths.is_empty() { + let spec_val = roots.reread(ctx, spec)?; + let hooked = ctx.block().call( + DOUBLE, + "js_module_dynamic_import_apply_hooks", + &[(DOUBLE, &spec_val)], + ); + let options_val = roots.reread(ctx, options)?; + return Ok(ctx.block().call( + DOUBLE, + "js_module_dynamic_import_fallback", + &[(DOUBLE, &hooked), (DOUBLE, &options_val)], + )); + } + + // Evaluate the runtime path string, apply registered loader hooks, + // then emit a chain of `js_string_equals` compares. Do this even + // for a single statically-resolved candidate: TypeScript types are + // erased at runtime and a hook may rewrite the specifier, so the + // candidate count does not prove that the runtime value matches. + // Skipping the compare here used to silently initialize the sole + // candidate for `load("./other.ts" as any)` and for hook redirects. + // Each + // successful compare resolves to its corresponding + // namespace global. The final fallback emits a rejected + // promise. + let raw_path_val = roots.reread(ctx, spec)?; + let path_val = ctx.block().call( DOUBLE, "js_module_dynamic_import_apply_hooks", - &[(DOUBLE, &spec_val)], + &[(DOUBLE, &raw_path_val)], ); - return Ok(ctx.block().call( - DOUBLE, - "js_module_dynamic_import_fallback", - &[(DOUBLE, &hooked)], - )); - } + let path = roots.adopt_emitted(ctx, Repr::Boxed, &path_val, true); + // Result phi slot: every successful match stores the + // promise (NaN-boxed POINTER_TAG f64) here, then jumps to + // a join block which loads and returns. Using an alloca + // keeps the IR straightforward without proper phi nodes. + let result_slot = ctx.block().alloca(DOUBLE); + let join_block_idx = ctx.new_block("dynamic_import_join"); - // Evaluate the runtime path string, apply registered loader hooks, - // then emit a chain of `js_string_equals` compares. Do this even - // for a single statically-resolved candidate: TypeScript types are - // erased at runtime and a hook may rewrite the specifier, so the - // candidate count does not prove that the runtime value matches. - // Skipping the compare here used to silently initialize the sole - // candidate for `load("./other.ts" as any)` and for hook redirects. - // Each - // successful compare resolves to its corresponding - // namespace global. The final fallback emits a rejected - // promise. - let raw_path_val = lower_expr(ctx, arg)?; - let path_val = ctx.block().call( - DOUBLE, - "js_module_dynamic_import_apply_hooks", - &[(DOUBLE, &raw_path_val)], - ); - // Result phi slot: every successful match stores the - // promise (NaN-boxed POINTER_TAG f64) here, then jumps to - // a join block which loads and returns. Using an alloca - // keeps the IR straightforward without proper phi nodes. - let result_slot = ctx.block().alloca(DOUBLE); - let join_block_idx = ctx.new_block("dynamic_import_join"); + // Unbox the path argument once into an i64 StringHeader*. + let path_handle = + ctx.block() + .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &path_val)]); - // Unbox the path argument once into an i64 StringHeader*. - let path_handle = - ctx.block() - .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &path_val)]); + // Pre-resolve target prefixes so we can skip paths that + // don't have a known target (driver dropped them). + let resolved: Vec<(String, String)> = paths + .iter() + .filter_map(|p| { + ctx.dynamic_import_path_to_prefix + .get(p) + .cloned() + .map(|tgt| (p.clone(), tgt)) + }) + .collect(); - // Pre-resolve target prefixes so we can skip paths that - // don't have a known target (driver dropped them). - let resolved: Vec<(String, String)> = paths - .iter() - .filter_map(|p| { - ctx.dynamic_import_path_to_prefix - .get(p) - .cloned() - .map(|tgt| (p.clone(), tgt)) - }) - .collect(); + for (i, (path_str, target_prefix)) in resolved.iter().enumerate() { + // Intern the path string so the compare against the + // runtime arg works on real StringHeader pointers. + let key_idx = ctx.strings.intern(path_str); + let key_entry = ctx.strings.entry(key_idx); + let key_handle_global = format!("@{}", key_entry.handle_global); - for (i, (path_str, target_prefix)) in resolved.iter().enumerate() { - // Intern the path string so the compare against the - // runtime arg works on real StringHeader pointers. - let key_idx = ctx.strings.intern(path_str); - let key_entry = ctx.strings.entry(key_idx); - let key_handle_global = format!("@{}", key_entry.handle_global); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_handle = + blk.call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &key_box)]); + let eq_i32 = blk.call( + I32, + "js_string_equals", + &[(I64, &path_handle), (I64, &key_handle)], + ); + let cond = blk.icmp_ne(I32, &eq_i32, "0"); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_handle = - blk.call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &key_box)]); - let eq_i32 = blk.call( - I32, - "js_string_equals", - &[(I64, &path_handle), (I64, &key_handle)], - ); - let cond = blk.icmp_ne(I32, &eq_i32, "0"); + let match_block_idx = ctx.new_block(&format!("dyn_import_match_{}", i)); + let next_label = if i + 1 < resolved.len() { + ctx.new_block(&format!("dyn_import_next_{}", i)) + } else { + ctx.new_block(&format!("dyn_import_reject_{}", i)) + }; + let match_label = ctx.block_label(match_block_idx); + let next_label_str = ctx.block_label(next_label); + ctx.block().cond_br(&cond, &match_label, &next_label_str); - let match_block_idx = ctx.new_block(&format!("dyn_import_match_{}", i)); - let next_label = if i + 1 < resolved.len() { - ctx.new_block(&format!("dyn_import_next_{}", i)) - } else { - ctx.new_block(&format!("dyn_import_reject_{}", i)) - }; - let match_label = ctx.block_label(match_block_idx); - let next_label_str = ctx.block_label(next_label); - ctx.block().cond_br(&cond, &match_label, &next_label_str); + // Match arm — call target's __init (idempotent), load + // namespace, wrap in promise, store into result_slot, + // branch to join. Issue #753: the init call is the + // only thing that triggers a Deferred target's body + // and namespace populator; for Eager targets the + // guard short-circuits. + ctx.current_block = match_block_idx; + let join_label = ctx.block_label(join_block_idx); + // #1671: known node-submodule target (sentinel prefix) → + // build its namespace via the runtime helper rather than a + // compiled-module init + namespace global. + let ns_val = if let Some(key) = target_prefix.strip_prefix("__node_submod__") { + let key = key.to_string(); + let submod_label = emit_string_literal_global(ctx, &key); + let submod_len = key.len(); + let install_sym = crate::nm_install::nm_submod_install_symbol(&key); + let blk = ctx.block(); + if let Some(s) = install_sym { + blk.call_void(s, &[]); + } + blk.call( + DOUBLE, + "js_node_submodule_namespace", + &[(PTR, &submod_label), (I32, &submod_len.to_string())], + ) + } else if let Some(name) = target_prefix.strip_prefix("__native_mod__") { + // #1673: general native builtin target in a multi-path + // (`import(cond ? 'node:crypto' : './local.ts')`) chain. + let name = name.to_string(); + let mod_label = emit_string_literal_global(ctx, &name); + let mod_len = name.len(); + let blk = ctx.block(); + if let Some(s) = crate::nm_install::nm_install_symbol(&name) { + blk.call_void(s, &[]); + } + if name == "wasi" { + blk.call(DOUBLE, "js_wasi_emit_warning", &[]); + } + blk.call( + DOUBLE, + "js_create_native_module_namespace", + &[(PTR, &mod_label), (I64, &mod_len.to_string())], + ) + } else { + let blk = ctx.block(); + blk.call_void(&format!("{}__init", target_prefix), &[]); + blk.load(DOUBLE, &format!("@__perry_ns_{}", target_prefix)) + }; + let blk = ctx.block(); + let promise = blk.call(I64, "js_promise_resolved", &[(DOUBLE, &ns_val)]); + let boxed = nanbox_pointer_inline(blk, &promise); + blk.store(DOUBLE, &boxed, &result_slot); + blk.br(&join_label); - // Match arm — call target's __init (idempotent), load - // namespace, wrap in promise, store into result_slot, - // branch to join. Issue #753: the init call is the - // only thing that triggers a Deferred target's body - // and namespace populator; for Eager targets the - // guard short-circuits. - ctx.current_block = match_block_idx; + // Move to the next compare block (or fallthrough to + // rejection on the last iteration). + ctx.current_block = next_label; + } + + // No-match fallthrough: runtime fallback (#6660) — a builtin + // specifier resolves like Node, everything else rejects with + // `ERR_MODULE_NOT_FOUND` (this arm used to reject with literal + // `undefined`). let join_label = ctx.block_label(join_block_idx); - // #1671: known node-submodule target (sentinel prefix) → - // build its namespace via the runtime helper rather than a - // compiled-module init + namespace global. - let ns_val = if let Some(key) = target_prefix.strip_prefix("__node_submod__") { - let key = key.to_string(); - let submod_label = emit_string_literal_global(ctx, &key); - let submod_len = key.len(); - let install_sym = crate::nm_install::nm_submod_install_symbol(&key); - let blk = ctx.block(); - if let Some(s) = install_sym { - blk.call_void(s, &[]); - } - blk.call( - DOUBLE, - "js_node_submodule_namespace", - &[(PTR, &submod_label), (I32, &submod_len.to_string())], - ) - } else if let Some(name) = target_prefix.strip_prefix("__native_mod__") { - // #1673: general native builtin target in a multi-path - // (`import(cond ? 'node:crypto' : './local.ts')`) chain. - let name = name.to_string(); - let mod_label = emit_string_literal_global(ctx, &name); - let mod_len = name.len(); - let blk = ctx.block(); - if let Some(s) = crate::nm_install::nm_install_symbol(&name) { - blk.call_void(s, &[]); - } - if name == "wasi" { - blk.call(DOUBLE, "js_wasi_emit_warning", &[]); - } - blk.call( - DOUBLE, - "js_create_native_module_namespace", - &[(PTR, &mod_label), (I64, &mod_len.to_string())], - ) - } else { - let blk = ctx.block(); - blk.call_void(&format!("{}__init", target_prefix), &[]); - blk.load(DOUBLE, &format!("@__perry_ns_{}", target_prefix)) - }; + let path_val = roots.reread_emitted(ctx, path); + let options_val = roots.reread(ctx, options)?; let blk = ctx.block(); - let promise = blk.call(I64, "js_promise_resolved", &[(DOUBLE, &ns_val)]); - let boxed = nanbox_pointer_inline(blk, &promise); - blk.store(DOUBLE, &boxed, &result_slot); + let fallback = blk.call( + DOUBLE, + "js_module_dynamic_import_fallback", + &[(DOUBLE, &path_val), (DOUBLE, &options_val)], + ); + blk.store(DOUBLE, &fallback, &result_slot); blk.br(&join_label); - // Move to the next compare block (or fallthrough to - // rejection on the last iteration). - ctx.current_block = next_label; - } - - // No-match fallthrough: runtime fallback (#6660) — a builtin - // specifier resolves like Node, everything else rejects with - // `ERR_MODULE_NOT_FOUND` (this arm used to reject with literal - // `undefined`). - let join_label = ctx.block_label(join_block_idx); - let blk = ctx.block(); - let fallback = blk.call( - DOUBLE, - "js_module_dynamic_import_fallback", - &[(DOUBLE, &path_val)], - ); - blk.store(DOUBLE, &fallback, &result_slot); - blk.br(&join_label); - - // Join: load result and return. - ctx.current_block = join_block_idx; - Ok(ctx.block().load(DOUBLE, &result_slot)) + // Join: load result and return. + ctx.current_block = join_block_idx; + Ok(ctx.block().load(DOUBLE, &result_slot)) + }) } // -------- ExternFuncRef as a value -------- diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index e221ce098f..d90926ea87 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1620,12 +1620,16 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // `ERR_MODULE_NOT_FOUND` Error (never literal `undefined`). The deferred // variant carries the #5230 compile-time deferral message for unknown // modules. - module.declare_function("js_module_dynamic_import_fallback", DOUBLE, &[DOUBLE]); module.declare_function( - "js_module_dynamic_import_deferred", + "js_module_dynamic_import_fallback", DOUBLE, &[DOUBLE, DOUBLE], ); + module.declare_function( + "js_module_dynamic_import_deferred", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE], + ); // #6644: `module.createRequire(...)` devirt entry — arms the nm/submod // install-all hooks before delegating (see js_process_get_builtin_module_devirt). module.declare_function("js_module_create_require_devirt", DOUBLE, &[DOUBLE]); diff --git a/crates/perry-hir/src/dynamic_import/tests.rs b/crates/perry-hir/src/dynamic_import/tests.rs index 05131df530..4b30dc7420 100644 --- a/crates/perry-hir/src/dynamic_import/tests.rs +++ b/crates/perry-hir/src/dynamic_import/tests.rs @@ -758,6 +758,7 @@ fn dynamic_import_visitors_keep_closure_and_toplevel_order_in_lockstep() { Expr::DynamicImport { paths: vec![], arg: Box::new(Expr::String(path.to_string())), + options: None, byte_offset: 0, deferred_error: None, synchronous: false, @@ -790,7 +791,20 @@ fn dynamic_import_visitors_keep_closure_and_toplevel_order_in_lockstep() { is_generator: false, is_strict: false, })); - module.init.push(Stmt::Expr(dynamic_import("toplevel"))); + let mut outer = dynamic_import("toplevel"); + if let Expr::DynamicImport { options, .. } = &mut outer { + *options = Some(Box::new(dynamic_import("options"))); + } + module.init.push(Stmt::Expr(outer)); + let with_options_hash = crate::stable_hash::hash_module(&module); + let mut without_options = module.clone(); + if let Some(Stmt::Expr(Expr::DynamicImport { options, .. })) = without_options.init.last_mut() { + *options = None; + } + assert_ne!( + with_options_hash, + crate::stable_hash::hash_module(&without_options) + ); let mut immutable = Vec::new(); for_each_dynamic_import(&module, &mut |expr| immutable.push(path(expr))); @@ -798,7 +812,7 @@ fn dynamic_import_visitors_keep_closure_and_toplevel_order_in_lockstep() { let mut mutable = Vec::new(); for_each_dynamic_import_mut(&mut module, &mut |expr| mutable.push(path(expr))); - assert_eq!(immutable, ["closure", "toplevel"]); + assert_eq!(immutable, ["closure", "toplevel", "options"]); assert_eq!(mutable, immutable); } diff --git a/crates/perry-hir/src/dynamic_import/visitors.rs b/crates/perry-hir/src/dynamic_import/visitors.rs index 191dc30e95..72a1ee254e 100644 --- a/crates/perry-hir/src/dynamic_import/visitors.rs +++ b/crates/perry-hir/src/dynamic_import/visitors.rs @@ -422,8 +422,11 @@ fn visit_expr_for_dyn_imports(expr: &mut Expr, f: &mut F) { f(expr); // After f mutates the node, still descend into the (possibly // unchanged) `arg` so nested dynamic imports are visited. - if let Expr::DynamicImport { arg, .. } = expr { + if let Expr::DynamicImport { arg, options, .. } = expr { visit_expr_for_dyn_imports(arg, f); + if let Some(options) = options { + visit_expr_for_dyn_imports(options, f); + } } return; } @@ -438,11 +441,14 @@ fn visit_expr_for_dyn_imports(expr: &mut Expr, f: &mut F) { } fn visit_expr_for_dyn_imports_ref(expr: &Expr, f: &mut F) { - if let Expr::DynamicImport { arg, .. } = expr { + if let Expr::DynamicImport { arg, options, .. } = expr { f(expr); // Mirror the `_mut` sibling: after reporting the node, still descend // into the `arg` so nested dynamic imports are visited. visit_expr_for_dyn_imports_ref(arg, f); + if let Some(options) = options { + visit_expr_for_dyn_imports_ref(options, f); + } return; } // Closure bodies — descend manually (the walker intentionally doesn't). diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 375066fd0e..b5b69c0456 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -2722,6 +2722,8 @@ pub enum Expr { DynamicImport { paths: Vec, arg: Box, + /// Runtime import options (`{ with: { type: "toml" } }`, etc.). + options: Option>, /// Byte offset (`span.lo.0`) of the `import(...)` call in its module's /// source, captured at lowering time. Used by the driver to resolve a /// `file:line` for the #5230 deferred-site notice (HIR `Expr` carries no diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs index 63244f1c91..03ffa406a2 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs @@ -150,6 +150,7 @@ pub(crate) fn try_dynamic_require( Ok(Some(Expr::DynamicImport { paths: Vec::new(), arg: Box::new(arg), + options: None, byte_offset: call.span.lo.0, deferred_error: None, synchronous: true, @@ -214,6 +215,7 @@ pub(crate) fn try_import_meta_require( Ok(Some(Expr::DynamicImport { paths: Vec::new(), arg: Box::new(lower_expr(ctx, arg)?), + options: None, byte_offset: call.span.lo.0, deferred_error: None, synchronous: true, diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index ed277d5c23..8af6aff797 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -783,13 +783,14 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result { tag(h, 12054); module.as_ref().hash(h); name.as_ref().hash(h); } Expr::WebAssemblyInstantiate { bytes, imports } => { tag(h, 12028); bytes.as_ref().hash(h); imports.hash(h); } Expr::WebAssemblyCallExport { instance, name, args, } => { tag(h, 12029); instance.as_ref().hash(h); name.as_ref().hash(h); args.hash(h); } - Expr::DynamicImport { paths, arg, byte_offset, deferred_error, synchronous } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); byte_offset.hash(h); deferred_error.hash(h); synchronous.hash(h); } + Expr::DynamicImport { paths, arg, options, byte_offset, deferred_error, synchronous } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); options.hash(h); byte_offset.hash(h); deferred_error.hash(h); synchronous.hash(h); } Expr::WorkerNew { paths, filename, options, is_eval } => { tag(h, 12055); for p in paths { p.hash(h); } diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index b4a15663b4..fe78215c1f 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -1916,9 +1916,12 @@ where } } - // Issue #100: dynamic import() — descend into the path arg. - Expr::DynamicImport { arg, .. } => { + // Import options can contain local references, calls, and nested imports. + Expr::DynamicImport { arg, options, .. } => { f(arg); + if let Some(options) = options { + f(options); + } } Expr::WorkerNew { filename, options, .. diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index dc2ca8c855..56cea7d120 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -1876,9 +1876,12 @@ where } } } - // Issue #100: dynamic import() — descend into the path arg. - Expr::DynamicImport { arg, .. } => { + // Import options can contain local references, calls, and nested imports. + Expr::DynamicImport { arg, options, .. } => { f(arg); + if let Some(options) = options { + f(options); + } } Expr::WorkerNew { filename, options, .. diff --git a/crates/perry-runtime/src/bun_compat/cli_utils.rs b/crates/perry-runtime/src/bun_compat/cli_utils.rs index 88a4f44d0c..be66817430 100644 --- a/crates/perry-runtime/src/bun_compat/cli_utils.rs +++ b/crates/perry-runtime/src/bun_compat/cli_utils.rs @@ -143,24 +143,37 @@ pub fn js_bun_yaml() -> f64 { extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { let source = value_to_string(input); + match toml_parse_result(&source) { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + } +} + +/// Shared by Bun.TOML.parse and the runtime import loader. Returning errors +/// lets import() reject its promise without throwing through Rust I/O frames. +pub(crate) fn toml_parse_result(source: &str) -> Result { // `Value::from_str` in toml 1.x parses a single TOML value expression; // Bun.TOML.parse consumes a complete document, whose root is a table. - let parsed = match toml::from_str::(&source) { + let parsed = match toml::from_str::(source) { Ok(parsed) => parsed, - Err(error) => crate::exception::js_throw(syntax_error_value(&format!( - "Failed to parse TOML: {error}" - ))), + Err(error) => { + return Err(syntax_error_value(&format!( + "Failed to parse TOML: {error}" + ))) + } }; let json = match serde_json::to_string(&parsed) { Ok(json) => json, - Err(error) => crate::exception::js_throw(syntax_error_value(&format!( - "Failed to convert TOML value: {error}" - ))), + Err(error) => { + return Err(syntax_error_value(&format!( + "Failed to convert TOML value: {error}" + ))) + } }; let source = js_string_from_bytes(json.as_ptr(), json.len() as u32); match unsafe { crate::json::js_json_parse_result(source) } { - Ok(value) => f64::from_bits(value.bits()), - Err(error) => crate::exception::js_throw(error), + Ok(value) => Ok(f64::from_bits(value.bits())), + Err(error) => Err(error), } } diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index fa909d7924..c3f8874f9f 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -4,6 +4,8 @@ //! public function shape. Full CommonJS file/package resolution remains in the //! compiler-side CJS wrapper and future `Module._*` work. +mod data_import; + use crate::closure::{ js_closure_alloc, js_closure_get_capture_f64, js_closure_set_capture_f64, js_register_closure_arity, ClosureHeader, @@ -1218,7 +1220,9 @@ static KEEP_JS_MODULE_AMBIENT_REQUIRE_APPLY: extern "C" fn(f64) -> f64 = /// `deferred_note` carries the compile-time deferral message for #5230 sites /// (runtime-computed specifier, non-strict policy) so a genuinely unknown /// module still reports the site's `file:line`. -fn dynamic_import_fallback_promise(spec: f64, deferred_note: Option) -> f64 { +fn dynamic_import_fallback_promise(spec: f64, options: f64, deferred_note: Option) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let options = scope.root_nanbox_f64(options); // Arm the install-all hooks the way `getBuiltinModule`'s devirt entry does // (#6644): the namespace handed back below must dispatch methods even when // no static import of the module exists anywhere in the program. Codegen @@ -1236,6 +1240,21 @@ fn dynamic_import_fallback_promise(spec: f64, deferred_note: Option) -> crate::exception::string_header_to_string(crate::value::js_jsvalue_to_string(spec)) }, }; + match data_import::load(&spec_str, options.get_nanbox_f64()) { + Ok(Some(namespace)) => { + let namespace = scope.root_nanbox_f64(namespace); + return js_nanbox_pointer( + crate::promise::js_promise_resolved(namespace.get_nanbox_f64()) as i64, + ); + } + Err(error) => { + let error = scope.root_nanbox_f64(error); + return js_nanbox_pointer( + crate::promise::js_promise_rejected(error.get_nanbox_f64()) as i64 + ); + } + Ok(None) => {} + } if let Some(module_name) = supported_require_builtin(&spec_str) { let scope = crate::gc::RuntimeHandleScope::new(); let ns_handle = scope.root_nanbox_f64(require_builtin_value(module_name)); @@ -1323,14 +1342,14 @@ fn dynamic_import_javascript_data_url(specifier: &str) -> Option { /// arms (#6660). Returns a NaN-boxed promise; never throws synchronously /// (`import()` always rejects, per spec). #[no_mangle] -pub extern "C" fn js_module_dynamic_import_fallback(spec: f64) -> f64 { - dynamic_import_fallback_promise(spec, None) +pub extern "C" fn js_module_dynamic_import_fallback(spec: f64, options: f64) -> f64 { + dynamic_import_fallback_promise(spec, options, None) } /// Keepalive anchor (same pattern as the ambient-require anchors above). #[cfg(feature = "keepalive-anchors")] #[used] -static KEEP_JS_MODULE_DYNAMIC_IMPORT_FALLBACK: extern "C" fn(f64) -> f64 = +static KEEP_JS_MODULE_DYNAMIC_IMPORT_FALLBACK: extern "C" fn(f64, f64) -> f64 = js_module_dynamic_import_fallback; /// Codegen entry for #5230 *deferred* dynamic-import sites (runtime-computed @@ -1339,20 +1358,20 @@ static KEEP_JS_MODULE_DYNAMIC_IMPORT_FALLBACK: extern "C" fn(f64) -> f64 = /// `file:line`) to the requested module and native-build guidance. `msg` is the /// NaN-boxed deferral string. #[no_mangle] -pub extern "C" fn js_module_dynamic_import_deferred(spec: f64, msg: f64) -> f64 { +pub extern "C" fn js_module_dynamic_import_deferred(spec: f64, options: f64, msg: f64) -> f64 { let note = { let jv = JSValue::from_bits(msg.to_bits()); let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; unsafe { crate::string::js_string_key_bytes(jv, &mut sso) } .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) }; - dynamic_import_fallback_promise(spec, note) + dynamic_import_fallback_promise(spec, options, note) } /// Keepalive anchor (same pattern as the ambient-require anchors above). #[cfg(feature = "keepalive-anchors")] #[used] -static KEEP_JS_MODULE_DYNAMIC_IMPORT_DEFERRED: extern "C" fn(f64, f64) -> f64 = +static KEEP_JS_MODULE_DYNAMIC_IMPORT_DEFERRED: extern "C" fn(f64, f64, f64) -> f64 = js_module_dynamic_import_deferred; /// #6651 family regression guard: createRequire's resolver must never drift diff --git a/crates/perry-runtime/src/module_require/data_import.rs b/crates/perry-runtime/src/module_require/data_import.rs new file mode 100644 index 0000000000..376e9b7de5 --- /dev/null +++ b/crates/perry-runtime/src/module_require/data_import.rs @@ -0,0 +1,98 @@ +//! Runtime data-file loaders for import attributes (#10104). + +use super::{js_nanbox_pointer, js_string_from_bytes, set_field_rooted, string_value, undefined}; +use crate::gc::RuntimeHandleScope; +use crate::value::JSValue; +use std::path::Path; + +fn string_bytes(value: f64) -> Option { + let value = JSValue::from_bits(value.to_bits()); + let mut sso = [0; crate::value::SHORT_STRING_MAX_LEN]; + // SAFETY: the helper validates the value's string representation. + unsafe { crate::string::js_string_key_bytes(value, &mut sso) } + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) +} + +fn property(value: f64, key: &[u8]) -> Result { + // Catch accessors here so errors reject import() and the caller's root + // scopes are dropped normally, even with the longjmp exception transport. + crate::exception::catch_js_throw(|| unsafe { + crate::value::js_get_property(value, key.as_ptr() as i64, key.len() as i64) + }) +} + +fn io_error(path: &str, error: std::io::Error) -> f64 { + let code = match error.kind() { + std::io::ErrorKind::NotFound => "ENOENT", + std::io::ErrorKind::PermissionDenied => "EACCES", + std::io::ErrorKind::IsADirectory => "EISDIR", + _ => "EIO", + }; + let message = format!("{code}: cannot import '{path}': {error}"); + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + crate::node_submodules::register_error_code_pub(message, code); + js_nanbox_pointer(crate::error::js_error_new_with_message(message) as i64) +} + +/// `None` leaves builtin/code-module resolution to the existing fallback. +pub(super) fn load(specifier: &str, options: f64) -> Result, f64> { + if JSValue::from_bits(options.to_bits()).is_undefined() { + return Ok(None); + } + let scope = RuntimeHandleScope::new(); + let attributes = scope.root_nanbox_f64(property(options, b"with")?); + if JSValue::from_bits(attributes.get_nanbox_f64().to_bits()).is_undefined() { + return Ok(None); + } + let loader = string_bytes(property(attributes.get_nanbox_f64(), b"type")?); + let Some(loader @ ("toml" | "json" | "text" | "file")) = loader.as_deref() else { + return Ok(None); + }; + let path = if specifier.starts_with("file://") { + let url = scope.root_nanbox_f64(string_value(specifier)); + let decoded = crate::exception::catch_js_throw(|| { + crate::url::js_url_file_url_to_path(url.get_nanbox_f64(), undefined()) + })?; + string_bytes(decoded).expect("fileURLToPath returns a string") + } else if Path::new(specifier).is_absolute() { + specifier.to_owned() + } else { + return Ok(None); + }; + + let value = if loader == "file" { + let metadata = std::fs::metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.is_dir() { + return Err(io_error(&path, std::io::ErrorKind::IsADirectory.into())); + } + string_value(&path) + } else { + let bytes = std::fs::read(&path).map_err(|error| io_error(&path, error))?; + let source = String::from_utf8_lossy(&bytes); + match loader { + "text" => string_value(&source), + "json" => { + let source = source.strip_prefix('\u{feff}').unwrap_or(&source); + let source = js_string_from_bytes(source.as_ptr(), source.len() as u32); + // SAFETY: source is a live runtime string; parse_result returns + // a SyntaxError value instead of throwing on invalid JSON. + unsafe { crate::json::js_json_parse_result(source) } + .map(|value| f64::from_bits(value.bits()))? + } + #[cfg(feature = "bun-cli-utils")] + "toml" => crate::bun_compat::toml_parse_result(&source)?, + // Optimized builds retain bun-cli-utils for sites with options. + // A deliberately minimal runtime still uses the deferred error. + _ => return Ok(None), + } + }; + let value = scope.root_nanbox_f64(value); + let namespace = scope.root_raw_mut_ptr(crate::object::js_object_alloc_null_proto(0, 1)); + set_field_rooted(&namespace, "default", value.get_nanbox_f64()); + Ok(Some(namespace.with_mut_ptr( + |object: *mut crate::object::ObjectHeader| js_nanbox_pointer(object as i64), + ))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-runtime/src/module_require/data_import/tests.rs b/crates/perry-runtime/src/module_require/data_import/tests.rs new file mode 100644 index 0000000000..a5fd853da1 --- /dev/null +++ b/crates/perry-runtime/src/module_require/data_import/tests.rs @@ -0,0 +1,157 @@ +use super::*; +use crate::module_require::{js_module_dynamic_import_deferred, js_module_dynamic_import_fallback}; + +fn json(source: &str) -> f64 { + let source = js_string_from_bytes(source.as_ptr(), source.len() as u32); + unsafe { crate::json::js_json_parse_result(source) } + .map(|value| f64::from_bits(value.bits())) + .expect("valid test JSON") +} + +fn settled(promise: f64, state: i32) -> f64 { + let promise = + JSValue::from_bits(promise.to_bits()).as_pointer::() as *mut _; + assert_eq!(crate::promise::js_promise_state(promise), state); + crate::promise::js_promise_result(promise) +} + +fn error_code(value: f64) -> Option<&'static str> { + crate::node_submodules::error_code_for_error( + JSValue::from_bits(value.to_bits()).as_pointer::(), + ) +} + +#[test] +fn runtime_data_import_loaders_and_rejections() { + let directory = std::env::temp_dir().join(format!( + "perry-data-import-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&directory).unwrap(); + let scope = RuntimeHandleScope::new(); + let path = directory.join("config space # %.data"); + let path = path.to_str().unwrap(); + let file_url = crate::url::node_compat::path_to_file_url_string(path, cfg!(windows)); + let note = scope.root_nanbox_f64(string_value("deferred test site")); + + for (loader, contents) in [ + ("json", "{\"answer\":42}"), + ("text", "hello π\n"), + ("file", "asset"), + ] { + std::fs::write(path, contents).unwrap(); + let options = + scope.root_nanbox_f64(json(&format!("{{\"with\":{{\"type\":\"{loader}\"}}}}"))); + for specifier in [path, file_url.as_str()] { + let specifier = scope.root_nanbox_f64(string_value(specifier)); + for deferred in [false, true] { + let promise = if deferred { + js_module_dynamic_import_deferred( + specifier.get_nanbox_f64(), + options.get_nanbox_f64(), + note.get_nanbox_f64(), + ) + } else { + js_module_dynamic_import_fallback( + specifier.get_nanbox_f64(), + options.get_nanbox_f64(), + ) + }; + let namespace = scope.root_nanbox_f64(settled(promise, 1)); + let value = property(namespace.get_nanbox_f64(), b"default").unwrap(); + match loader { + "json" => assert_eq!(property(value, b"answer").unwrap(), 42.0), + "text" => assert_eq!(string_bytes(value).as_deref(), Some(contents)), + "file" => assert_eq!(string_bytes(value).as_deref(), Some(path)), + _ => unreachable!(), + } + } + } + } + + #[cfg(feature = "bun-cli-utils")] + { + std::fs::write(path, "provider = \"anthropic\"\n[settings]\nretry = 3\n").unwrap(); + let specifier = scope.root_nanbox_f64(string_value(&file_url)); + let options = scope.root_nanbox_f64(json(r#"{"with":{"type":"toml"}}"#)); + let promise = js_module_dynamic_import_deferred( + specifier.get_nanbox_f64(), + options.get_nanbox_f64(), + note.get_nanbox_f64(), + ); + let namespace = scope.root_nanbox_f64(settled(promise, 1)); + let table = + scope.root_nanbox_f64(property(namespace.get_nanbox_f64(), b"default").unwrap()); + assert_eq!( + string_bytes(property(table.get_nanbox_f64(), b"provider").unwrap()).as_deref(), + Some("anthropic") + ); + let settings = property(table.get_nanbox_f64(), b"settings").unwrap(); + assert_eq!(property(settings, b"retry").unwrap(), 3.0); + } + + for loader in ["json", "toml"] { + if loader == "toml" && !cfg!(feature = "bun-cli-utils") { + continue; + } + std::fs::write(path, "invalid = [").unwrap(); + let options = + scope.root_nanbox_f64(json(&format!("{{\"with\":{{\"type\":\"{loader}\"}}}}"))); + let specifier = scope.root_nanbox_f64(string_value(&file_url)); + let promise = + js_module_dynamic_import_fallback(specifier.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + string_bytes(property(error.get_nanbox_f64(), b"name").unwrap()).as_deref(), + Some("SyntaxError") + ); + } + + let options = scope.root_nanbox_f64(json(r#"{"with":{"type":"text"}}"#)); + for specifier in [ + "./relative.data", + "https://example.invalid/data", + "unknown-package", + ] { + let specifier = scope.root_nanbox_f64(string_value(specifier)); + let promise = + js_module_dynamic_import_fallback(specifier.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + error_code(error.get_nanbox_f64()), + Some("ERR_MODULE_NOT_FOUND") + ); + } + // Existing runtime files without a supported data attribute remain deferred. + let specifier = scope.root_nanbox_f64(string_value(path)); + let promise = js_module_dynamic_import_deferred( + specifier.get_nanbox_f64(), + undefined(), + note.get_nanbox_f64(), + ); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + string_bytes(property(error.get_nanbox_f64(), b"message").unwrap()).as_deref(), + Some("deferred test site") + ); + + std::fs::remove_file(path).unwrap(); + // URL conversion failures reject the promise instead of throwing here. + let invalid_url = scope.root_nanbox_f64(string_value(&file_url.replace("%20", "%2F"))); + let promise = + js_module_dynamic_import_fallback(invalid_url.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + string_bytes(property(error.get_nanbox_f64(), b"name").unwrap()).as_deref(), + Some("TypeError") + ); + let promise = + js_module_dynamic_import_fallback(specifier.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!(error_code(error.get_nanbox_f64()), Some("ENOENT")); + std::fs::remove_dir(directory).unwrap(); +} diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 879c542fde..c0d149c90d 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -1483,6 +1483,7 @@ mod tests { vec![Stmt::Return(Some(Expr::DynamicImport { paths: vec!["./alpha".to_string()], arg: Box::new(Expr::String("./alpha".to_string())), + options: None, byte_offset: 0, deferred_error: None, synchronous: true, diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 4b29ebdca6..8247d97149 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -790,12 +790,36 @@ fn collect_module_one( if let perry_hir::Expr::DynamicImport { paths, arg, + options, byte_offset, synchronous, .. } = expr { let synchronous = *synchronous; + ctx.uses_dynamic_import_options |= options.is_some(); + let may_load_data = match options.as_deref() { + None | Some(perry_hir::Expr::Undefined) => false, + Some(perry_hir::Expr::Object(fields)) => fields.iter().any(|(key, value)| { + key == "with" + && match value { + perry_hir::Expr::Object(attributes) => { + attributes.iter().any(|(key, value)| { + key == "type" + && match value { + perry_hir::Expr::String(loader) => matches!( + loader.as_str(), + "toml" | "json" | "text" | "file" + ), + _ => true, + } + }) + } + _ => true, + } + }), + _ => true, + }; if !paths.is_empty() { // Already resolved (e.g. a second pass on the same module). return; @@ -822,6 +846,14 @@ fn collect_module_one( return; } for p in &set { + // Data files selected by import attributes are read at + // runtime, including literal absolute paths. Do not + // feed their contents to the TypeScript compiler. + if may_load_data + && (p.starts_with("file://") || std::path::Path::new(p).is_absolute()) + { + continue; + } if p.starts_with("data:text/javascript,") { ctx.uses_data_url_dynamic_import = true; } diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index 6e34d3783c..b2e25ccde3 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -131,7 +131,7 @@ pub(crate) fn auto_optimized_cache_key( tokio_bindings.sort_unstable(); tokio_bindings.dedup(); format!( - "{}|{}|{}|wasm={}|napi={}|regex={}|temporal={}|ee={}|url={}|norm={}|seg={}|loc={}|intlns={}|gns={}{}{}{}{}{}{}{}{}{}|diag={}|dgram={}|http2={}|nodetest={}|dyneval={}|tokio={}|sizeopt={}|anchors={}|v={}", + "{}|{}|{}|wasm={}|napi={}|regex={}|temporal={}|ee={}|url={}|norm={}|seg={}|loc={}|intlns={}|gns={}{}{}{}{}{}{}{}{}{}|diag={}|dgram={}|http2={}|nodetest={}|dyneval={}|importopts={}|tokio={}|sizeopt={}|anchors={}|v={}", feature_arg, panic_abort_safe, target_str, @@ -169,6 +169,7 @@ pub(crate) fn auto_optimized_cache_key( perry_hir::has_deferred_dynamic_code_sites() || ctx.native_module_imports.contains("vm") || ctx.uses_data_url_dynamic_import, + ctx.uses_dynamic_import_options, tokio_bindings.join(","), format!( "{}{}{}", @@ -221,7 +222,10 @@ pub(crate) fn auto_optimized_cross_features( if !ctx.native_addons.is_empty() { cross_features.push("perry-runtime/node-api-host".to_string()); } - if ctx.bun_platform || ctx.native_module_imports.contains("bun") { + if ctx.bun_platform + || ctx.native_module_imports.contains("bun") + || ctx.uses_dynamic_import_options + { cross_features.push("perry-runtime/bun-cli-utils".to_string()); } // Binary-size feature gating (kept in sync with the inline list on `main`): diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 8743028e78..ebb015fd59 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1202,6 +1202,22 @@ fn bun_usage_enables_cli_utility_runtime_pack() { ); } +#[test] +fn dynamic_import_options_retain_toml_and_change_cache_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let without = CompilationContext::new(dir.path().to_path_buf()); + let mut with = CompilationContext::new(dir.path().to_path_buf()); + with.uses_dynamic_import_options = true; + let cross = auto_optimized_cross_features(&with, &std::collections::BTreeSet::new(), &[]); + assert!(cross + .iter() + .any(|feature| feature == "perry-runtime/bun-cli-utils")); + assert_ne!( + auto_optimized_cache_key("", true, false, None, &with, &[]), + auto_optimized_cache_key("", true, false, None, &without, &[]), + ); +} + #[test] fn data_url_dynamic_import_enables_dyn_eval_and_changes_cache_key() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 5d7f27ea1a..28af24b81a 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -795,6 +795,8 @@ pub struct CompilationContext { /// The runtime evaluates these modules through the dyn-eval interpreter, /// so auto-optimized archives must retain that otherwise optional feature. pub uses_data_url_dynamic_import: bool, + /// Import options can select a runtime TOML loader without a Bun import. + pub uses_dynamic_import_options: bool, /// Whether any TS module calls global `fetch()` (which routes to /// reqwest in perry-stdlib's http-client feature). pub uses_fetch: bool, @@ -1246,6 +1248,7 @@ impl CompilationContext { geisterhand_port: 7676, native_module_imports: BTreeSet::new(), uses_data_url_dynamic_import: false, + uses_dynamic_import_options: false, uses_fetch: false, uses_crypto_builtins: false, uses_zlib_brotli: false, diff --git a/crates/perry/tests/issue_10104_runtime_data_imports.rs b/crates/perry/tests/issue_10104_runtime_data_imports.rs new file mode 100644 index 0000000000..4b3d2be48f --- /dev/null +++ b/crates/perry/tests/issue_10104_runtime_data_imports.rs @@ -0,0 +1,109 @@ +//! Runtime import attributes and OpenCode's legacy TOML config migration. +use std::process::Command; + +fn compile_and_run(source: &str) -> String { + let directory = tempfile::tempdir().unwrap(); + let entry = directory.path().join("main.ts"); + let binary = directory + .path() + .join(if cfg!(windows) { "main.exe" } else { "main" }); + let source = source.replace( + "\"__LITERAL_DATA_PATH__\"", + &serde_json::to_string(&directory.path().join("literal-data")).unwrap(), + ); + std::fs::write(&entry, source).unwrap(); + let mut compiler = Command::new(env!("CARGO_BIN_EXE_perry")); + // #7354: LLVM RS4GC does not support Windows exception funclets yet. + // Exercise the supported shadow-root path for async rejection tests. + if cfg!(windows) { + compiler.env("PERRY_RS4GC", "0"); + } + let compile = compiler + .current_dir(directory.path()) + .args(["compile", "--no-cache"]) + .arg(&entry) + .arg("-o") + .arg(&binary) + .output() + .unwrap(); + assert!( + compile.status.success(), + "compile failed: {}", + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(binary) + .current_dir(directory.path()) + .output() + .unwrap(); + assert!( + run.status.success(), + "run failed: {}\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert!( + run.stderr.is_empty(), + "unexpected stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).unwrap().replace("\r\n", "\n") +} + +#[test] +fn runtime_data_loaders_and_legacy_migration_match_bun() { + let output = compile_and_run(include_str!( + "../../../test-files/test_dynamic_import_data_10104.ts" + )); + assert_eq!( + output, + concat!( + "toml anthropic claude-sonnet-4-5 dark\n", + "json 42\njson url true\n", + "text \"hello π\\n\"\nfile true\n", + "options 42 so\n", + "bad toml true\nbad json SyntaxError true\n", + "migrated true\n", + "{\n \"model\": \"anthropic/claude-sonnet-4-5\",\n", + " \"$schema\": \"https://opencode.ai/config.json\",\n", + " \"theme\": \"dark\"\n}\n", + ) + ); +} + +#[test] +fn literal_absolute_data_path_is_read_after_compilation() { + let output = compile_and_run( + r#" +import { writeFileSync } from "node:fs"; +writeFileSync("__LITERAL_DATA_PATH__", "answer = 42\n"); +const mod = await import("__LITERAL_DATA_PATH__", { with: { type: "toml" } }); +console.log(mod.default.answer); +"#, + ); + assert_eq!(output, "42\n"); +} + +#[test] +fn runtime_code_imports_keep_the_deferred_error() { + let output = compile_and_run( + r#" +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +const source = join(process.cwd(), "runtime-code.js"); +writeFileSync(source, "throw new Error('code must not execute')"); +const load = (specifier: string, options?: any) => import(specifier, options); +await load(pathToFileURL(source).href).catch((error: any) => console.log(error.code)); +await load(source, { with: { type: "javascript" } }).catch((error: any) => console.log(error.code)); +await load("./runtime-code.js", { with: { type: "text" } }).catch((error: any) => console.log(error.code)); +const badToml = join(process.cwd(), "broken.toml"); +writeFileSync(badToml, "broken = ["); +await load(pathToFileURL(badToml).href, { with: { type: "toml" } }) + .catch((error: any) => console.log(error.name, error instanceof SyntaxError)); +"#, + ); + assert_eq!( + output, + "ERR_MODULE_NOT_FOUND\nERR_MODULE_NOT_FOUND\nERR_MODULE_NOT_FOUND\nSyntaxError true\n" + ); +} diff --git a/docs/src/language/limitations.md b/docs/src/language/limitations.md index e6bf02a474..da502514b0 100644 --- a/docs/src/language/limitations.md +++ b/docs/src/language/limitations.md @@ -55,7 +55,7 @@ build, while still failing loudly (and catchably) if that path runs. ### Dynamic `import()` with a runtime-computed specifier (#5230) -A dynamic `import(spec)` whose `spec` is only known at runtime (a plugin loader +For code modules, a dynamic `import(spec)` whose `spec` is only known at runtime (a plugin loader building a path from a variable) is subject to the **same defer/notice/strict policy** as `eval`. By default it compiles to a rejected `Promise` carrying a descriptive `Error` (so `await import(spec)` throws *only if reached*), is @@ -112,6 +112,29 @@ contract with `plugin: ["some-npm-plugin"]`, one report, and successful builtin bundled imports afterward. Full OpenTUI rendering remains part of the integration acceptance in #10107. +Data files can be loaded at runtime using import attributes (#10104). The +specifier must be an absolute filesystem path or a `file://` URL, and the +result has a `default` export: + +| Import attribute `type` | Default export | +|---|---| +| `"toml"` | Parsed TOML table, using the same parser as `Bun.TOML.parse` | +| `"json"` | Parsed JSON value | +| `"text"` | File contents as a string | +| `"file"` | Filesystem path as a string | + +```typescript,no-test +import { pathToFileURL } from "node:url"; +const { default: config } = await import(pathToFileURL(configPath).href, { + with: { type: "toml" }, +}); +``` + +TOML and JSON parse failures reject with `SyntaxError`. Missing files reject +with an I/O error. These loaders do not load runtime code modules or resolve +relative paths, package names, or network URLs. Strict mode still rejects +runtime-computed specifiers at compile time as described below. + ### Strict mode: refuse at compile time To make every runtime-unknown site a hard compile-time error instead, opt into diff --git a/test-files/test_dynamic_import_data_10104.ts b/test-files/test_dynamic_import_data_10104.ts new file mode 100644 index 0000000000..4560dacecc --- /dev/null +++ b/test-files/test_dynamic_import_data_10104.ts @@ -0,0 +1,62 @@ +// Bun 1.3.14 oracle, also run by crates/perry/tests/issue_10104_runtime_data_imports.rs. +import { writeFileSync, readFileSync, existsSync } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; + +const directory = process.cwd(); +const legacy = path.join(directory, "config space # %.legacy"); +writeFileSync(legacy, 'provider = "anthropic"\nmodel = "claude-sonnet-4-5"\ntheme = "dark"\n'); + +const load = (specifier: string, type: string) => import(specifier, { with: { type } }); +const table = await load(pathToFileURL(legacy).href, "toml"); +console.log("toml", table.default.provider, table.default.model, table.default.theme); + +const jsonPath = path.join(directory, "data.json"); +writeFileSync(jsonPath, '{"answer":42,"nested":{"enabled":true}}'); +const type = "json"; +const captured = (specifier: string) => import(specifier, { with: { type } }); +console.log("json", (await captured(jsonPath)).default.answer); +console.log("json url", (await load(pathToFileURL(jsonPath).href, "json")).default.nested.enabled); + +const textPath = path.join(directory, "content space # %.data"); +writeFileSync(textPath, "hello π\n"); +console.log("text", JSON.stringify((await load(pathToFileURL(textPath).href, "text")).default)); +const assetPath = path.join(directory, "asset space # %.data"); +writeFileSync(assetPath, "asset"); +console.log("file", path.normalize((await load(pathToFileURL(assetPath).href, "file")).default) === assetPath); + +let order = ""; +function specifier() { order += "s"; return jsonPath; } +function options() { order += "o"; return { with: { type: "json" } }; } +console.log("options", (await import(specifier(), options())).default.answer, order); + +const badToml = path.join(directory, "bad.toml"); +const badJson = path.join(directory, "bad.json"); +writeFileSync(badToml, "broken = ["); +writeFileSync(badJson, "{broken"); +await load(pathToFileURL(badToml).href, "toml").catch((error: any) => { + // Bun 1.3.14 wraps TOML loader diagnostics in BuildMessage; Perry's + // requested SyntaxError contract is asserted separately in the Rust suite. + console.log("bad toml", !!error); +}); +await load(badJson, "json").catch((error: any) => { + console.log("bad json", error.name, error instanceof SyntaxError); +}); + +// OpenCode's legacy migration: destructure the imported default, combine the +// provider/model, write config.json, and remove the old file. The swallowing +// catch is intentional: the pre-fix runtime silently skipped this migration. +let result: any = {}; +await import(pathToFileURL(legacy).href, { with: { type: "toml" } }) + .then(async (mod) => { + const { provider, model, ...rest } = mod.default; + if (provider && model) result.model = `${provider}/${model}`; + result["$schema"] = "https://opencode.ai/config.json"; + result = Object.assign(result, rest); + await fs.writeFile(path.join(directory, "config.json"), JSON.stringify(result, null, 2)); + await fs.unlink(legacy); + }) + .catch(() => {}); +console.log("migrated", !existsSync(legacy)); +console.log(readFileSync(path.join(directory, "config.json"), "utf8")); From c6260c570f5764f5f413821f1deb9e0d5a8e10f3 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:09:56 +0200 Subject: [PATCH 26/36] fix(bun): support inert runtime plugin registration (cherry picked from commit 6e42f4d0fbd3ab4933e4addf83f87d513c47729c) --- changelog.d/10100-bun-plugin.md | 6 + .../perry-api-manifest/src/entries/part_4.rs | 9 + .../tests/stub_inventory.rs | 2 + .../src/lower_call/native_table/bun.rs | 9 + .../lower/expr_call/module_class_static.rs | 1 + .../src/lower/expr_call/module_static.rs | 1 + .../native_module/imported_module_dispatch.rs | 4 +- crates/perry-hir/src/lower/expr_member.rs | 1 + crates/perry-runtime/src/bun_compat/mod.rs | 2 + crates/perry-runtime/src/bun_compat/plugin.rs | 212 ++++++++++++++++++ crates/perry-runtime/src/module_require.rs | 10 + .../callable_export_arity_table.rs | 5 +- .../native_module/callable_export_check.rs | 1 + .../native_module/callable_export_table.rs | 1 + .../object/native_module/callable_exports.rs | 4 + .../native_module/constructor_exports.rs | 9 +- .../src/object/native_module/module_keys.rs | 1 + .../native_module_dispatch/dispatch_a_c.rs | 1 + crates/perry/tests/issue_10100_bun_plugin.rs | 203 +++++++++++++++++ docs/api/perry.d.ts | 4 +- docs/src/api/reference.md | 3 +- docs/src/stdlib/other.md | 21 ++ test-files/test_bun_plugin.ts | 11 + 23 files changed, 514 insertions(+), 7 deletions(-) create mode 100644 changelog.d/10100-bun-plugin.md create mode 100644 crates/perry-runtime/src/bun_compat/plugin.rs create mode 100644 crates/perry/tests/issue_10100_bun_plugin.rs create mode 100644 test-files/test_bun_plugin.ts diff --git a/changelog.d/10100-bun-plugin.md b/changelog.d/10100-bun-plugin.md new file mode 100644 index 0000000000..ddce249c8e --- /dev/null +++ b/changelog.d/10100-bun-plugin.md @@ -0,0 +1,6 @@ +Fix Bun runtime plugin registration during OpenTUI startup (#10100). Imported +`plugin` and `Bun.plugin` run setup synchronously with inert loader hooks, preserve +async setup completion and errors, and expose a no-op `clearAll`. Deferred JSX +imports explain the missing runtime transform in native builds. The API manifest +and generated reference document the limited support; Bun feature detection and +local shadowing keep their existing behavior. diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index f47abbcd70..e16285f1d1 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1103,6 +1103,15 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ class("bun", "Transpiler"), method("bun", "Transpiler", false, None), method("bun", "build", false, None), + method_sig( + "bun", + "plugin", + false, + None, + &[p_any("plugin")], + TypeSpec::Any, + ) + .stub_note("setup runs synchronously; runtime loader hooks and clearAll are inert (#10100)"), method("bun", "transformSync", true, Some("Transpiler")), method("bun", "transform", true, Some("Transpiler")), method("bun", "scanImports", true, Some("Transpiler")), diff --git a/crates/perry-api-manifest/tests/stub_inventory.rs b/crates/perry-api-manifest/tests/stub_inventory.rs index 01ef743285..c8f60c1e79 100644 --- a/crates/perry-api-manifest/tests/stub_inventory.rs +++ b/crates/perry-api-manifest/tests/stub_inventory.rs @@ -93,6 +93,7 @@ fn stub_inventory_matches_known_clusters() { // event-loop refcount), mongodb.findOne (parsed document), // exponential-backoff options (honored, incl. retry predicate). ("#4917", 9), + ("#10100", 1), ]; let expected_map: BTreeMap = expected.iter().map(|(k, v)| (k.to_string(), *v)).collect(); @@ -108,6 +109,7 @@ fn stubs_only_appear_in_allowlisted_modules() { // A stub flag showing up on a module not in this list is almost // certainly an accident — fail loud so it gets triaged. let allowed = [ + "bun", "stream/web", "streams", "v8", diff --git a/crates/perry-codegen/src/lower_call/native_table/bun.rs b/crates/perry-codegen/src/lower_call/native_table/bun.rs index 772555b0cb..cf99a88dd9 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -9,6 +9,15 @@ use super::*; /// `Bun.stdin` / `Bun.stdout` / `Bun.stderr` are property reads (handled by /// `js_native_module_property_by_name`), not rows here. pub(crate) const BUN_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "bun", + has_receiver: false, + method: "plugin", + class_filter: None, + runtime: "js_bun_plugin", + args: &[NA_F64], + ret: NR_F64, + }, NativeModSig { module: "bun:jsc", has_receiver: false, diff --git a/crates/perry-hir/src/lower/expr_call/module_class_static.rs b/crates/perry-hir/src/lower/expr_call/module_class_static.rs index e9ac036358..898f6cbd87 100644 --- a/crates/perry-hir/src/lower/expr_call/module_class_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_class_static.rs @@ -83,6 +83,7 @@ pub(super) fn try_module_class_static( | ("bun", "semver") | ("bun", "JSONL") | ("bun", "hash") + | ("bun", "plugin") ); // Unimplemented-API gate (#463) for the chained // `mod.X.Y()` case. The lower_member gate fires diff --git a/crates/perry-hir/src/lower/expr_call/module_static.rs b/crates/perry-hir/src/lower/expr_call/module_static.rs index 74464b446f..a201879f7e 100644 --- a/crates/perry-hir/src/lower/expr_call/module_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_static.rs @@ -76,6 +76,7 @@ pub(super) fn try_module_static_methods( if matches!( method_ident.sym.as_ref(), "stringWidth" + | "plugin" | "hash" | "deepEquals" | "stripANSI" diff --git a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs index 64ee68890a..b7f69763d7 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs @@ -114,12 +114,12 @@ pub(super) fn try_imported_module_dispatch( } // `import { YAML } from "bun"; YAML.parse(...)` and the other // #9600 value namespaces call closures stored on that value. - // Do not reinterpret `.parse`/`.order`/`.xxHash64` as a + // Do not reinterpret `.parse`/`.order`/`.xxHash64`/`.clearAll` as a // top-level `bun` native method. if module_name == "bun" && matches!( imported_method, - Some("YAML" | "TOML" | "semver" | "JSONL" | "hash") + Some("YAML" | "TOML" | "semver" | "JSONL" | "hash" | "plugin") ) { return Ok(Err(args)); diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 9083288627..77d1b3cbb6 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -334,6 +334,7 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re | "stdout" | "stderr" | "stringWidth" + | "plugin" | "hash" | "YAML" | "TOML" diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index e91831f4c0..80f96bc208 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -27,6 +27,7 @@ mod cli_utils; mod cli_utils_stub; mod glob; mod jsc; +mod plugin; mod spawn; mod string_width; mod width_tables; @@ -50,6 +51,7 @@ pub use cli_utils::*; pub use cli_utils_stub::*; pub use glob::js_bun_glob_new; pub use jsc::js_bun_jsc_heap_stats; +pub use plugin::{decorate_bun_plugin, js_bun_plugin}; pub use spawn::{js_bun_spawn, js_bun_terminal_new}; pub use string_width::bun_string_width; pub use wyhash::wyhash; diff --git a/crates/perry-runtime/src/bun_compat/plugin.rs b/crates/perry-runtime/src/bun_compat/plugin.rs new file mode 100644 index 0000000000..a43fb9548e --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/plugin.rs @@ -0,0 +1,212 @@ +//! Inert Bun runtime plugin registration (#10100). Setup runs now, but native +//! builds cannot install JavaScript module loaders after compilation. + +use super::{key_ptr, object_field, undefined}; +use crate::closure::{js_closure_alloc, js_register_closure_arity, ClosureHeader}; +use crate::gc::{RuntimeHandle, RuntimeHandleScope}; +use crate::object::{js_object_alloc, js_object_set_field_by_name}; +use crate::value::{js_nanbox_pointer, JSValue}; + +extern "C" fn ignore(_closure: *const ClosureHeader, _value: f64) -> f64 { + undefined() +} + +fn noop() -> f64 { + js_register_closure_arity(ignore as *const u8, 1); + js_nanbox_pointer(js_closure_alloc(ignore as *const u8, 0) as i64) +} + +fn set(scope: &RuntimeHandleScope, object: &RuntimeHandle, name: &[u8], value: f64) { + let value = scope.root_nanbox_f64(value); + let key = scope.root_string_ptr(key_ptr(name)); + key.with_mut_ptr(|key| { + js_object_set_field_by_name( + JSValue::from_bits(object.get_nanbox_f64().to_bits()) + .as_pointer::() + .cast_mut(), + key, + value.get_nanbox_f64(), + ); + }); +} + +pub fn decorate_bun_plugin(value: f64) -> f64 { + let scope = RuntimeHandleScope::new(); + let plugin = scope.root_nanbox_f64(value); + let clear = scope.root_nanbox_f64(noop()); + let raw = JSValue::from_bits(plugin.get_nanbox_f64().to_bits()).as_pointer::(); + crate::closure::closure_set_dynamic_prop(raw as usize, "clearAll", clear.get_nanbox_f64()); + plugin.get_nanbox_f64() +} + +#[cfg(panic = "abort")] +#[no_mangle] +pub extern "C" fn js_bun_plugin(plugin: f64) -> f64 { + register(plugin) +} + +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub extern "C-unwind" fn js_bun_plugin(plugin: f64) -> f64 { + register(plugin) +} + +fn register(plugin: f64) -> f64 { + let scope = RuntimeHandleScope::new(); + let plugin = scope.root_nanbox_f64(plugin); + let is_function = !crate::fs::extract_closure_ptr(plugin.get_nanbox_f64()).is_null(); + let setup = if is_function { + plugin.get_nanbox_f64() + } else { + object_field(plugin.get_nanbox_f64(), b"setup").unwrap_or_else(undefined) + }; + if crate::fs::extract_closure_ptr(setup).is_null() { + let message = + key_ptr(b"Bun.plugin expects a setup function or an object with a callable setup"); + let error = crate::error::js_typeerror_new(message); + crate::exception::js_throw(js_nanbox_pointer(error as i64)); + } + let setup = scope.root_nanbox_f64(setup); + crate::stub_diag::perry_runtime_stub( + "Bun.plugin", + "setup runs synchronously; runtime loader hooks are ignored in native builds", + Some("#10100"), + ); + if crate::stub_diag::strict_stubs_enabled() { + let message = + key_ptr(b"Bun.plugin runtime transforms are not available in the native build"); + crate::node_submodules::register_error_code_pub(message, "ERR_PERRY_UNIMPLEMENTED"); + let error = crate::error::js_error_new_with_message(message); + crate::exception::js_throw(js_nanbox_pointer(error as i64)); + } + let build = scope.root_nanbox_f64(js_nanbox_pointer(js_object_alloc(0, 6) as i64)); + let hook = scope.root_nanbox_f64(noop()); + for name in [ + b"onLoad".as_slice(), + b"onResolve", + b"onStart", + b"onEnd", + b"module", + ] { + set(&scope, &build, name, hook.get_nanbox_f64()); + } + set( + &scope, + &build, + b"config", + js_nanbox_pointer(js_object_alloc(0, 0) as i64), + ); + let previous = scope.root_nanbox_f64(crate::object::js_implicit_this_set(if is_function { + undefined() + } else { + plugin.get_nanbox_f64() + })); + let result = crate::exception::catch_js_throw(|| unsafe { + let args = [build.get_nanbox_f64()]; + crate::closure::js_native_call_value(setup.get_nanbox_f64(), args.as_ptr(), 1) + }); + crate::object::js_implicit_this_set(previous.get_nanbox_f64()); + let result = match result { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + }; + if crate::promise::js_value_is_promise(result) != 0 { + // Bun forwards async setup's promise, including its fulfillment value + // and rejection. Hooks stay inert on either side of await. + result + } else { + undefined() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + static SETUP_CALLS: AtomicU32 = AtomicU32::new(0); + static LOADER_CALLS: AtomicU32 = AtomicU32::new(0); + + fn closure(func: extern "C" fn(*const ClosureHeader, f64) -> f64) -> f64 { + js_register_closure_arity(func as *const u8, 1); + js_nanbox_pointer(js_closure_alloc(func as *const u8, 0) as i64) + } + + extern "C" fn loader(_closure: *const ClosureHeader, _args: f64) -> f64 { + LOADER_CALLS.fetch_add(1, Ordering::SeqCst); + undefined() + } + + extern "C" fn setup(_closure: *const ClosureHeader, build: f64) -> f64 { + SETUP_CALLS.fetch_add(1, Ordering::SeqCst); + let scope = RuntimeHandleScope::new(); + let build = scope.root_nanbox_f64(build); + let callback = scope.root_nanbox_f64(closure(loader)); + // The builder must remain usable across collection in user setup. + crate::gc::js_gc_collect(); + for name in [ + b"onLoad".as_slice(), + b"onResolve", + b"onStart", + b"onEnd", + b"module", + ] { + let hook = object_field(build.get_nanbox_f64(), name).expect("builder hook"); + let args = [undefined(), callback.get_nanbox_f64()]; + let result = unsafe { crate::closure::js_native_call_value(hook, args.as_ptr(), 2) }; + assert_eq!(result.to_bits(), undefined().to_bits()); + } + assert!(object_field(build.get_nanbox_f64(), b"config").is_some()); + 42.0 + } + + #[test] + fn calls_setup_for_objects_and_functions_without_running_hooks() { + SETUP_CALLS.store(0, Ordering::SeqCst); + LOADER_CALLS.store(0, Ordering::SeqCst); + let scope = RuntimeHandleScope::new(); + let setup = scope.root_nanbox_f64(closure(setup)); + let object = scope.root_nanbox_f64(js_nanbox_pointer(js_object_alloc(0, 1) as i64)); + set(&scope, &object, b"setup", setup.get_nanbox_f64()); + assert_eq!( + js_bun_plugin(object.get_nanbox_f64()).to_bits(), + undefined().to_bits() + ); + assert_eq!( + js_bun_plugin(setup.get_nanbox_f64()).to_bits(), + undefined().to_bits() + ); + assert_eq!(SETUP_CALLS.load(Ordering::SeqCst), 2); + assert_eq!(LOADER_CALLS.load(Ordering::SeqCst), 0); + } + + extern "C" fn async_setup(_closure: *const ClosureHeader, _build: f64) -> f64 { + js_nanbox_pointer(crate::promise::js_promise_resolved(42.0) as i64) + } + + #[test] + fn async_setup_preserves_its_result() { + let scope = RuntimeHandleScope::new(); + let result = scope.root_nanbox_f64(js_bun_plugin(closure(async_setup))); + assert_eq!( + crate::promise::js_value_is_promise(result.get_nanbox_f64()), + 1 + ); + let promise = JSValue::from_bits(result.get_nanbox_f64().to_bits()) + .as_pointer::() + .cast_mut(); + assert_eq!(crate::promise::js_promise_state(promise), 1); + assert_eq!(crate::promise::js_promise_value(promise), 42.0); + } + + #[test] + fn clear_all_is_a_callable_noop() { + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(decorate_bun_plugin(closure(setup))); + let raw = JSValue::from_bits(value.get_nanbox_f64().to_bits()).as_pointer::(); + let clear = crate::closure::closure_get_dynamic_prop(raw as usize, "clearAll"); + assert!(!crate::fs::extract_closure_ptr(clear).is_null()); + let result = unsafe { crate::closure::js_native_call_value(clear, std::ptr::null(), 0) }; + assert_eq!(result.to_bits(), undefined().to_bits()); + } +} diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index c3f8874f9f..997364a9ea 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1277,6 +1277,16 @@ fn dynamic_import_fallback_promise(spec: f64, options: f64, deferred_note: Optio distribution, or compile the module into the binary through a \ statically resolvable import()." ); + // #10100: a TSX/JSX specifier additionally fails for a more specific + // reason — the native build ships no runtime transform and Bun.plugin + // loader hooks are inert — so name that on top of the AOT explanation + // rather than instead of it. + let path = spec_str.split(['?', '#']).next().unwrap_or(&spec_str); + if path.ends_with(".tsx") || path.ends_with(".jsx") { + message.push_str(&format!( + "; '{spec_str}' needs a runtime transform for this file type that the native build does not include (Bun.plugin loader hooks are inert)" + )); + } if let Some(note) = deferred_note { message.push(' '); message.push_str(¬e); diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index e90820f12b..db7572caf6 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -6,8 +6,8 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option Some(1), ("bun", "deepEquals" | "generateHeapSnapshot" | "spawn" | "write") => Some(2), ("bun", "wrapAnsi") => Some(3), @@ -306,6 +306,7 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("hash", 1), ("listen", 1), ("pathToFileURL", 1), + ("plugin", 1), ("serve", 1), ("spawn", 2), ("stringWidth", 1), diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 1525b75ef4..1d00e34dc9 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -51,6 +51,7 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st | "hash" | "listen" | "pathToFileURL" + | "plugin" | "serve" | "spawn" | "stringWidth" diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index c361cd4726..c23c9dd544 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -97,6 +97,7 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "hash", "listen", "pathToFileURL", + "plugin", "serve", "spawn", "stringWidth", diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 431382981a..73fc029d8f 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -135,6 +135,10 @@ pub fn bound_native_callable_export_value(module_name: &str, property_name: &str crate::process::module_source_map_attach_constructor(crate::value::js_nanbox_get_pointer( value.get_nanbox_f64(), ) as usize); + } else if export_module_name == "bun" && property_name == "plugin" { + // Decorate once at creation so named imports, namespaces, and saved + // Bun.plugin values share the same callable and clearAll property. + crate::bun_compat::decorate_bun_plugin(value.get_nanbox_f64()); } else if let Some(attach) = super::super::native_module_registry::nm_attach_lookup(export_module_name) { diff --git a/crates/perry-runtime/src/object/native_module/constructor_exports.rs b/crates/perry-runtime/src/object/native_module/constructor_exports.rs index c61960817b..6c0f032fdd 100644 --- a/crates/perry-runtime/src/object/native_module/constructor_exports.rs +++ b/crates/perry-runtime/src/object/native_module/constructor_exports.rs @@ -31,7 +31,14 @@ pub(crate) fn is_native_module_constructor_export(module: &str, property: &str) "assert" | "assert/strict" => matches!(property, "doesNotReject" | "rejects"), "bun" => matches!( property, - "build" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth" | "write" + "build" + | "file" + | "fileURLToPath" + | "hash" + | "pathToFileURL" + | "plugin" + | "stringWidth" + | "write" ), "buffer.Buffer" => property == "of", "console" => matches!( diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index 09efe3f2bd..6142ceac1b 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1664,6 +1664,7 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati b"isStandaloneExecutable", b"listen", b"pathToFileURL", + b"plugin", b"semver", b"serve", b"spawn", diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index b1c5d8d864..e83db693c2 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -274,6 +274,7 @@ pub(crate) unsafe fn nm_dispatch_bun(ctx: &NmCtx, module_name: &str, method_name match (module_name, method_name) { ("bun:jsc", "heapStats") => crate::bun_compat::js_bun_jsc_heap_stats(arg(0)), ("bun", "spawn") => crate::bun_compat::js_bun_spawn(arg(0), arg(1)), + ("bun", "plugin") => crate::bun_compat::js_bun_plugin(arg(0)), ("bun", "Terminal") => crate::bun_compat::js_bun_terminal_new(arg(0)), ("bun", "serve") => { let ptr = diff --git a/crates/perry/tests/issue_10100_bun_plugin.rs b/crates/perry/tests/issue_10100_bun_plugin.rs new file mode 100644 index 0000000000..36003b15c4 --- /dev/null +++ b/crates/perry/tests/issue_10100_bun_plugin.rs @@ -0,0 +1,203 @@ +//! Bun's inert runtime plugin registrar must let OpenTUI finish module init. + +use std::process::Command; + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().unwrap(); + let entry = dir.path().join("main.ts"); + let binary = dir + .path() + .join(if cfg!(windows) { "main.exe" } else { "main" }); + std::fs::write(&entry, source).unwrap(); + let mut compiler = Command::new(env!("CARGO_BIN_EXE_perry")); + // LLVM's statepoint pass rejects Windows exception funclets (#7354). + if cfg!(windows) { + compiler.env("PERRY_RS4GC", "0"); + } + let output = compiler + .current_dir(dir.path()) + .args(["compile", "--no-cache"]) + .arg(&entry) + .arg("-o") + .arg(&binary) + .output() + .unwrap(); + assert!( + output.status.success(), + "compile failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let output = Command::new(binary) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "run failed: {:?}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .unwrap() + .replace("\r\n", "\n") +} + +#[test] +fn setup_is_synchronous_and_hooks_are_inert() { + let stdout = compile_and_run( + r#" +import { plugin } from "bun"; +import * as bun from "bun"; +let calls = 0; +let hooks = 0; +const result = plugin({ name: "x", setup(build) { + calls++; + console.log("receiver", this.name); + build.onLoad({ filter: /a/ }, () => { hooks++; return {}; }); + build.onResolve({ filter: /a/ }, () => { hooks++; return {}; }); + build.onStart(() => { hooks++; }); + build.onEnd(() => { hooks++; }); + build.module("virtual:x", () => { hooks++; return {}; }); + build.config.target = "bun"; + console.log("config", build.config.target); + return 42; +} }); +console.log("sync", calls, hooks, result === undefined); +Bun.plugin((build) => { build.onLoad({ filter: /x/ }, () => ({})); calls++; }); +bun.plugin({ name: "namespace", setup() { calls++; } }); +const saved = Bun.plugin; +saved({ name: "value", setup() { calls++; } }); +console.log("clear type", typeof plugin.clearAll, typeof Bun.plugin.clearAll); +console.log("export", Object.keys(bun).includes("plugin"), saved === plugin, saved.clearAll === plugin.clearAll); +console.log("clear", plugin.clearAll() === undefined, Bun.plugin.clearAll() === undefined, saved.clearAll() === undefined, bun.plugin.clearAll() === undefined); +console.log("count", calls, "detect", typeof Bun); +function shadowed() { + const Bun = { plugin(fn) { fn("local"); } }; + Bun.plugin((value) => console.log("shadow", value)); +} +shadowed(); +"#, + ); + assert_eq!(stdout, "receiver x\nconfig bun\nsync 1 0 true\nclear type function function\nexport true true true\nclear true true true true\ncount 4 detect undefined\nshadow local\n"); +} + +#[test] +fn async_setup_and_errors_propagate() { + let stdout = compile_and_run( + r#" +import { plugin } from "bun"; +let phase = 0; +const pending = plugin({ name: "async", async setup(build) { + phase = 1; + await Promise.resolve(); + build.onLoad({ filter: /x/ }, () => { throw new Error("loader ran"); }); + phase = 2; + return 42; +} }); +console.log("before", phase, pending instanceof Promise); +console.log("await", await pending, phase); +try { plugin({ name: "throw", setup() { throw new Error("setup failed"); } }); } +catch (e) { console.log("throw", e.message); } +try { await plugin({ name: "reject", async setup() { await Promise.resolve(); throw new Error("async failed"); } }); } +catch (e) { console.log("reject", e.message); } +try { plugin({ name: "bad", setup: 123 }); } +catch (e) { console.log("invalid", e.name); } +plugin(() => console.log("recovered")); +"#, + ); + assert_eq!(stdout, "before 1 true\nawait 42 2\nthrow setup failed\nreject async failed\ninvalid TypeError\nrecovered\n"); +} + +#[test] +fn opentui_install_state_survives_repeated_calls() { + // Reduced from @opentui/solid 0.4.5's solid-plugin.js and + // runtime-plugin-support-configure.js. Keep the actual Symbol.for keys, + // nullish initialization, setup closure and module-init call ordering. + let stdout = compile_and_run( + r#" +import { plugin as registerBunPlugin } from "bun"; +const solidTransformStateKey = Symbol.for("opentui.solid.transform"); +const runtimePluginSupportInstalledKey = Symbol.for("opentui.solid.runtime-plugin-support"); +const getSolidTransformState = () => { + const state = globalThis; + state[solidTransformStateKey] ??= { installed: false }; + return state[solidTransformStateKey]; +}; +function ensureSolidTransformPlugin() { + const state = getSolidTransformState(); + if (state.installed) return false; + registerBunPlugin({ name: "bun-plugin-solid", setup: (build) => { + build.onLoad({ filter: /\.[jt]sx(?:[?#].*)?$/ }, async () => { + throw new Error("runtime transform must not run"); + }); + } }); + state.installed = true; + return true; +} +function ensureRuntimePluginSupport() { + const state = globalThis; + const install = state[runtimePluginSupportInstalledKey]; + if (install) return false; + ensureSolidTransformPlugin(); + registerBunPlugin({ name: "opentui-runtime", setup(build) { + build.onResolve({ filter: /^@opentui\// }, () => { throw new Error("resolver ran"); }); + build.onLoad({ filter: /.*/, namespace: "opentui" }, () => { throw new Error("loader ran"); }); + } }); + state[runtimePluginSupportInstalledKey] = { installed: true }; + return true; +} +console.log("install", ensureRuntimePluginSupport(), ensureRuntimePluginSupport()); +console.log("solid", getSolidTransformState().installed, ensureSolidTransformPlugin()); +console.log("past TUI runtime module init"); +"#, + ); + assert_eq!( + stdout, + "install true false\nsolid true false\npast TUI runtime module init\n" + ); +} + +#[test] +fn dynamic_namespaces_expose_plugin_and_clear_all() { + let stdout = compile_and_run( + r#" +async function main() { +const spec = "bun"; +const bun = await import(spec); +bun.plugin({ name: "dynamic", setup() { console.log("dynamic setup"); } }); +console.log("dynamic clear", bun.plugin.clearAll() === undefined); +const required = require("bun"); +const register = required.plugin; +register(() => console.log("required setup")); +console.log("required clear", register.clearAll() === undefined); +} +main(); +"#, + ); + assert_eq!( + stdout, + "dynamic setup\ndynamic clear true\nrequired setup\nrequired clear true\n" + ); +} + +#[test] +fn deferred_jsx_import_explains_native_transform_boundary() { + let stdout = compile_and_run( + r#" +import { plugin } from "bun"; +plugin({ name: "jsx", setup(build) { build.onLoad({ filter: /jsx|tsx/ }, () => ({})); } }); +async function load(specifier: string) { + try { await import(specifier); } + catch (error) { + console.log(error.code, error.message.includes("runtime transform"), + error.message.includes("native build does not include"), error.message.includes("main.ts:")); + } +} +await load("./user-plugin.tsx"); +await load("file:///plugins/user.jsx?version=1#entry"); +await load("./ordinary.js"); +"#, + ); + assert_eq!(stdout, "ERR_MODULE_NOT_FOUND true true true\nERR_MODULE_NOT_FOUND true true true\nERR_MODULE_NOT_FOUND false false true\n"); +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index fe456f0ae9..2990471c99 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2092 entries across 137 modules +// Coverage: 2093 entries across 137 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -392,6 +392,8 @@ declare module "bun" { export function listen(...args: any[]): any; /** stdlib */ export function pathToFileURL(...args: any[]): any; + /** stdlib @perryStub setup runs synchronously; runtime loader hooks and clearAll are inert (#10100) */ + export function plugin(plugin: any): any; /** stdlib */ export function serve(options: any): any; /** stdlib */ diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 6d89347cfc..15439f8240 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3051 entries across 139 modules. +Total: 3052 entries across 139 modules. ## Modules @@ -439,6 +439,7 @@ Total: 3051 entries across 139 modules. - `hash` — module - `listen` — module - `pathToFileURL` — module +- `plugin` — module ⚠ **stub** — setup runs synchronously; runtime loader hooks and clearAll are inert (#10100) - `scan` — instance *(class: `Transpiler`)* - `scanImports` — instance *(class: `Transpiler`)* - `serve` — module diff --git a/docs/src/stdlib/other.md b/docs/src/stdlib/other.md index 6c9260fa2f..9bed2ebc46 100644 --- a/docs/src/stdlib/other.md +++ b/docs/src/stdlib/other.md @@ -213,6 +213,27 @@ onmessage = (event) => { ``` +## Bun runtime plugin registration + +`import { plugin } from "bun"` and `Bun.plugin` accept a plugin object with +`name` and `setup`, or a setup function. Perry calls setup synchronously with +a builder containing inert `onLoad`, `onResolve`, `onStart`, `onEnd`, and +`module` methods and an empty, mutable `config` object. Registered hooks are +ignored. Synchronous setup returns `undefined`; asynchronous setup returns a +promise that settles when setup completes. Setup errors propagate to the caller. +`plugin.clearAll()` and `Bun.plugin.clearAll()` return `undefined`. + +This lets libraries such as OpenTUI finish their runtime plugin installation +bookkeeping. It does not add runtime source transforms or load user JavaScript +plugins into a native binary. An unresolved dynamic import of a `.tsx` or `.jsx` +file reports that its file type needs a runtime transform the native build does +not include. Statically compiled imports use the normal compiler pipeline. +The registrar is marked as partial support in the API manifest and follows +`PERRY_STUB_DIAG` and `PERRY_STRICT_STUBS`. + +As with other Bun shims, only member access is lowered: bare `typeof Bun` +remains `"undefined"`, and a local binding named `Bun` takes precedence. + ## bun:jsc `heapStats()` and `heapStats(true)` return memory diagnostics for the calling diff --git a/test-files/test_bun_plugin.ts b/test-files/test_bun_plugin.ts new file mode 100644 index 0000000000..49b0bd5076 --- /dev/null +++ b/test-files/test_bun_plugin.ts @@ -0,0 +1,11 @@ +// #10100: setup must run during registration, without invoking loader hooks. +import { plugin } from "bun"; +let installed = false; +plugin({ name: "startup", setup(build) { + build.onLoad({ filter: /a/ }, () => { throw new Error("loader ran"); }); + installed = true; +} }); +console.log(installed); +Bun.plugin((build) => { build.onResolve({ filter: /a/ }, () => ({})); }); +console.log(Bun.plugin.clearAll() === undefined); +console.log(typeof Bun); From 6bea654c6234a6a7b8a8da56291d024947b23634 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:10:15 +0200 Subject: [PATCH 27/36] docs: key Bun plugin changeset to PR #10130 (cherry picked from commit 022ce5405cb21060e008a3716f6af8479a45cc2c) --- changelog.d/{10100-bun-plugin.md => 10130-bun-plugin.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10100-bun-plugin.md => 10130-bun-plugin.md} (100%) diff --git a/changelog.d/10100-bun-plugin.md b/changelog.d/10130-bun-plugin.md similarity index 100% rename from changelog.d/10100-bun-plugin.md rename to changelog.d/10130-bun-plugin.md From c7ecdd4de7536641657958ac415ecafed209e36c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 14:56:07 +0200 Subject: [PATCH 28/36] feat: add build-time defines and import.meta.resolve (cherry picked from commit 5642e00c7ccf1d1fe13c6346794c10e910b0ff04) --- Cargo.lock | 1 + changelog.d/10101-build-defines-resolve.md | 9 + .../native/native_runtime_branch.rs | 16 + .../src/runtime_decls/objects.rs | 2 + .../src/lower/expr_call/intrinsics/require.rs | 31 ++ crates/perry-hir/src/lower/expr_member.rs | 1 + crates/perry-hir/src/lower/expr_misc.rs | 11 + crates/perry-parser/Cargo.toml | 1 + crates/perry-parser/src/defines.rs | 298 ++++++++++++++++++ crates/perry-parser/src/lib.rs | 2 + crates/perry-runtime/src/module_require.rs | 2 + .../src/module_require/import_meta_resolve.rs | 233 ++++++++++++++ crates/perry/src/commands/compile.rs | 1 + .../perry/src/commands/compile/build_cache.rs | 19 +- .../src/commands/compile/collect_modules.rs | 5 + .../compile/collect_modules/import_helpers.rs | 8 +- .../collect_modules/import_meta_resolve.rs | 125 ++++++++ crates/perry/src/commands/compile/defines.rs | 80 +++++ crates/perry/src/commands/compile/resolve.rs | 11 +- .../src/commands/compile/run_pipeline.rs | 4 +- crates/perry/src/commands/compile/types.rs | 10 + crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + .../tests/issue_10101_defines_resolve.rs | 224 +++++++++++++ docs/src/cli/flags.md | 54 ++++ docs/src/getting-started/project-config.md | 4 + scripts/build_opencode.test.ts | 48 +++ scripts/build_opencode.ts | 65 ++++ 28 files changed, 1258 insertions(+), 9 deletions(-) create mode 100644 changelog.d/10101-build-defines-resolve.md create mode 100644 crates/perry-parser/src/defines.rs create mode 100644 crates/perry-runtime/src/module_require/import_meta_resolve.rs create mode 100644 crates/perry/src/commands/compile/collect_modules/import_meta_resolve.rs create mode 100644 crates/perry/src/commands/compile/defines.rs create mode 100644 crates/perry/tests/issue_10101_defines_resolve.rs create mode 100644 scripts/build_opencode.test.ts create mode 100644 scripts/build_opencode.ts diff --git a/Cargo.lock b/Cargo.lock index dfcdef31bd..9ac95c834e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6388,6 +6388,7 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_parser 32.0.0", + "swc_ecma_transforms_base", "swc_ecma_visit", "thiserror 1.0.69", ] diff --git a/changelog.d/10101-build-defines-resolve.md b/changelog.d/10101-build-defines-resolve.md new file mode 100644 index 0000000000..85bee76ed2 --- /dev/null +++ b/changelog.d/10101-build-defines-resolve.md @@ -0,0 +1,9 @@ +Add repeatable `perry compile --define NAME=EXPR` and `perry.json` defines for +build-time constants, including dotted names, JSON snapshots, scoped identifier +replacement, and constant `typeof` guards. Effective defines invalidate build +and object caches while existing `package.json` literal defines remain compatible. + +Support `import.meta.resolve(specifier[, parent])` for compile-time module and +asset URLs and runtime package resolution from a directory or file URL. Add an +OpenCode release-define harness and regressions for native worker entry discovery. +Recognize Windows absolute worker paths during module resolution. diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index 3eb80d6e0c..653483e59c 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -1,6 +1,22 @@ { if module == "__perry_runtime" && class_name.is_none() && object.is_none() { match method { + "importMetaResolve" | "importMetaResolveValue" => { + let values = args + .iter() + .map(|arg| lower_expr(ctx, arg)) + .collect::>>()?; + let values = values + .iter() + .map(|value| (DOUBLE, value.as_str())) + .collect::>(); + let name = if method == "importMetaResolve" { + "js_import_meta_resolve" + } else { + "js_import_meta_resolve_value" + }; + return Ok(ctx.block().call(DOUBLE, name, &values)); + } "iteratorNextResult" => { let iter = args.first().map_or_else( || Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))), diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 686f8abf6b..e909ba79a7 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -375,6 +375,8 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // Next.js wall 53: runtime `require(absolutePath.json)` disk fallback. module.declare_function("js_require_json_disk", DOUBLE, &[DOUBLE]); module.declare_function("js_require_resolve_node_modules", DOUBLE, &[DOUBLE, DOUBLE]); + module.declare_function("js_import_meta_resolve", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]); + module.declare_function("js_import_meta_resolve_value", DOUBLE, &[DOUBLE]); module.declare_function("js_globalthis_seed_async_local_storage", VOID, &[]); // Next.js wall 54: runtime `require(absolutePath.js)` -> AOT-compiled module. module.declare_function("js_register_path_module_partial", VOID, &[DOUBLE, DOUBLE]); diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs index 03ffa406a2..14e1cf451c 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs @@ -188,6 +188,37 @@ pub(crate) fn try_import_meta_require( { return Ok(None); } + let is_resolve = match &member.prop { + ast::MemberProp::Ident(name) => name.sym == "resolve", + ast::MemberProp::Computed(key) => matches!(strip_require_wrappers(&key.expr), + ast::Expr::Lit(ast::Lit::Str(name)) if name.value.as_str() == Some("resolve")), + _ => false, + }; + if is_resolve && call.args.len() <= 2 && call.args.iter().all(|arg| arg.spread.is_none()) { + let specifier = call + .args + .first() + .map(|arg| lower_expr(ctx, &arg.expr)) + .transpose()? + .unwrap_or(Expr::Undefined); + let parent = call + .args + .get(1) + .map(|arg| lower_expr(ctx, &arg.expr)) + .transpose()? + .unwrap_or(Expr::Undefined); + return Ok(Some(Expr::NativeMethodCall { + module: "__perry_runtime".into(), + class_name: None, + object: None, + method: "importMetaResolve".into(), + args: vec![ + specifier, + parent, + Expr::String(ctx.source_file_path.clone()), + ], + })); + } let is_require = match &member.prop { ast::MemberProp::Ident(name) => name.sym == "require", ast::MemberProp::Computed(key) => matches!(strip_require_wrappers(&key.expr), diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 77d1b3cbb6..d6f5b3f391 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -272,6 +272,7 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re "dirname" => Expr::String(dirname), "filename" => Expr::String(filename), "require" => super::expr_misc::import_meta_require_value(ctx), + "resolve" => super::expr_misc::import_meta_resolve_value(ctx), // Unknown property — undefined matches the spec'd // "missing property on a frozen object" behavior of // import.meta in Node / Bun. diff --git a/crates/perry-hir/src/lower/expr_misc.rs b/crates/perry-hir/src/lower/expr_misc.rs index 67e17d0437..c97f798cd6 100644 --- a/crates/perry-hir/src/lower/expr_misc.rs +++ b/crates/perry-hir/src/lower/expr_misc.rs @@ -388,6 +388,7 @@ pub(super) fn lower_meta_prop( ("dirname".to_string(), Expr::String(dirname)), ("filename".to_string(), Expr::String(filename)), ("require".to_string(), import_meta_require_value(ctx)), + ("resolve".to_string(), import_meta_resolve_value(ctx)), ])) } ast::MetaPropKind::NewTarget => { @@ -432,6 +433,16 @@ pub(crate) fn import_meta_require_value(ctx: &mut LoweringContext) -> Expr { Expr::LocalGet(id) } +pub(crate) fn import_meta_resolve_value(ctx: &LoweringContext) -> Expr { + Expr::NativeMethodCall { + module: "__perry_runtime".into(), + class_name: None, + object: None, + method: "importMetaResolveValue".into(), + args: vec![Expr::String(ctx.source_file_path.clone())], + } +} + /// Issue #444: compute the `(url, dirname, filename)` triplet exposed via /// `import.meta`. Mirrors Node 20+ semantics — `url` is `file://`, /// `filename` is the absolute file path, `dirname` is its parent directory. diff --git a/crates/perry-parser/Cargo.toml b/crates/perry-parser/Cargo.toml index b11bbab19c..8f0ca4e0c6 100644 --- a/crates/perry-parser/Cargo.toml +++ b/crates/perry-parser/Cargo.toml @@ -13,6 +13,7 @@ swc_ecma_parser.workspace = true swc_ecma_ast.workspace = true swc_ecma_visit.workspace = true swc_common.workspace = true +swc_ecma_transforms_base.workspace = true thiserror.workspace = true anyhow.workspace = true diff --git a/crates/perry-parser/src/defines.rs b/crates/perry-parser/src/defines.rs new file mode 100644 index 0000000000..23af84aa58 --- /dev/null +++ b/crates/perry-parser/src/defines.rs @@ -0,0 +1,298 @@ +//! Scope-aware build-time expression substitution. Cached ASTs stay unchanged. + +use std::collections::BTreeMap; + +use anyhow::{bail, Context, Result}; +use swc_common::{Globals, Mark, SyntaxContext, GLOBALS}; +use swc_ecma_ast as ast; +use swc_ecma_visit::{VisitMut, VisitMutWith}; + +#[derive(Clone, Debug, Default)] +pub struct Defines { + expressions: BTreeMap>, +} + +impl Defines { + pub fn parse(values: &BTreeMap) -> Result { + let mut expressions = BTreeMap::new(); + for (key, value) in values { + let key_expr = + parse_expression(key).with_context(|| format!("invalid define key {key:?}"))?; + if dotted_name(&key_expr).as_deref() != Some(key) { + bail!("invalid define key {key:?}: expected an identifier or dotted identifier"); + } + let expression = parse_expression(value) + .with_context(|| format!("invalid define value for {key}"))?; + if !valid_value(&expression) { + bail!("invalid define value for {key}: expected JSON or an identifier expression"); + } + expressions.insert(key.clone(), expression); + } + Ok(Self { expressions }) + } + + pub fn apply(&self, module: &ast::Module) -> Option { + if self.expressions.is_empty() { + return None; + } + Some(GLOBALS.set(&Globals::new(), || { + let unresolved = Mark::new(); + let mut module = module.clone(); + module.visit_mut_with(&mut swc_ecma_transforms_base::resolver( + unresolved, + Mark::new(), + false, + )); + module.visit_mut_with(&mut Substitute { + defines: self, + unresolved: SyntaxContext::empty().apply_mark(unresolved), + }); + // HIR uses lexical names; resolver contexts must not escape GLOBALS. + module.visit_mut_with(&mut ClearContexts); + module + })) + } +} + +fn parse_expression(source: &str) -> Result> { + let module = crate::parse_typescript(&format!("({source});"), "define.js")?; + let [ast::ModuleItem::Stmt(ast::Stmt::Expr(stmt))] = module.body.as_slice() else { + bail!("expected one expression"); + }; + let ast::Expr::Paren(paren) = stmt.expr.as_ref() else { + bail!("expected one expression") + }; + Ok(paren.expr.clone()) +} + +fn dotted_name(expr: &ast::Expr) -> Option { + match expr { + ast::Expr::Ident(ident) => Some(ident.sym.to_string()), + ast::Expr::Member(member) => { + let prop = match &member.prop { + ast::MemberProp::Ident(prop) => prop.sym.as_ref(), + ast::MemberProp::Computed(key) => match key.expr.as_ref() { + ast::Expr::Lit(ast::Lit::Str(prop)) => prop.value.as_str()?, + _ => return None, + }, + _ => return None, + }; + Some(format!("{}.{}", dotted_name(&member.obj)?, prop)) + } + _ => None, + } +} + +fn valid_value(expr: &ast::Expr) -> bool { + dotted_name(expr).is_some() || valid_json_value(expr) +} + +fn valid_json_value(expr: &ast::Expr) -> bool { + match expr { + ast::Expr::Lit(ast::Lit::Str(_) | ast::Lit::Bool(_) | ast::Lit::Num(_) | ast::Lit::Null(_)) => true, + ast::Expr::Unary(unary) => matches!(unary.op, ast::UnaryOp::Minus | ast::UnaryOp::Plus) + && matches!(unary.arg.as_ref(), ast::Expr::Lit(ast::Lit::Num(_))), + ast::Expr::Array(array) => array.elems.iter().all(|item| item.as_ref().is_some_and(|item| item.spread.is_none() && valid_json_value(&item.expr))), + ast::Expr::Object(object) => object.props.iter().all(|prop| matches!(prop, + ast::PropOrSpread::Prop(prop) if matches!(prop.as_ref(), ast::Prop::KeyValue(kv) + if matches!(kv.key, ast::PropName::Ident(_) | ast::PropName::Str(_) | ast::PropName::Num(_)) && valid_json_value(&kv.value)))), + _ => false, + } +} + +struct Substitute<'a> { + defines: &'a Defines, + unresolved: SyntaxContext, +} + +impl Substitute<'_> { + fn replacement(&self, expr: &ast::Expr) -> Option> { + let mut root = expr; + while let ast::Expr::Member(member) = root { + root = &member.obj; + } + let ast::Expr::Ident(root) = root else { + return None; + }; + if root.ctxt != self.unresolved { + return None; + } + self.defines.expressions.get(&dotted_name(expr)?).cloned() + } +} + +impl VisitMut for Substitute<'_> { + fn visit_mut_expr(&mut self, expr: &mut ast::Expr) { + if let Some(replacement) = self.replacement(expr) { + *expr = *replacement; + return; // Replacement expressions are not recursively substituted. + } + match expr { + ast::Expr::Assign(assign) => { + assign.left.visit_mut_with(self); + assign.right.visit_mut_with(self); + return; + } + ast::Expr::Update(_) => return, + ast::Expr::Unary(unary) if unary.op == ast::UnaryOp::Delete => return, + _ => {} + } + expr.visit_mut_children_with(self); + if let ast::Expr::Unary(unary) = expr { + if unary.op == ast::UnaryOp::TypeOf { + let kind = match unary.arg.as_ref() { + ast::Expr::Lit(ast::Lit::Str(_)) => Some("string"), + ast::Expr::Lit(ast::Lit::Bool(_)) => Some("boolean"), + ast::Expr::Lit(ast::Lit::Num(_)) => Some("number"), + ast::Expr::Lit(ast::Lit::Null(_)) => Some("object"), + value @ (ast::Expr::Object(_) | ast::Expr::Array(_)) + if valid_json_value(value) => + { + Some("object") + } + ast::Expr::Ident(id) + if id.sym == "undefined" + && (id.ctxt == self.unresolved + || id.ctxt == SyntaxContext::empty()) => + { + Some("undefined") + } + _ => None, + }; + if let Some(kind) = kind { + *expr = ast::Expr::Lit(ast::Lit::Str(ast::Str { + span: unary.span, + value: kind.into(), + raw: None, + })); + } + } + } + if let ast::Expr::Bin(binary) = expr { + if matches!(binary.op, ast::BinaryOp::EqEqEq | ast::BinaryOp::NotEqEq) { + let equal = match (binary.left.as_ref(), binary.right.as_ref()) { + (ast::Expr::Lit(ast::Lit::Str(a)), ast::Expr::Lit(ast::Lit::Str(b))) => { + Some(a.value == b.value) + } + (ast::Expr::Lit(ast::Lit::Bool(a)), ast::Expr::Lit(ast::Lit::Bool(b))) => { + Some(a.value == b.value) + } + (ast::Expr::Lit(ast::Lit::Num(a)), ast::Expr::Lit(ast::Lit::Num(b))) => { + Some(a.value == b.value) + } + _ => None, + }; + if let Some(equal) = equal { + *expr = ast::Expr::Lit(ast::Lit::Bool(ast::Bool { + span: binary.span, + value: if binary.op == ast::BinaryOp::EqEqEq { + equal + } else { + !equal + }, + })); + } + } + } + if let ast::Expr::Cond(conditional) = expr { + if let ast::Expr::Lit(ast::Lit::Bool(test)) = conditional.test.as_ref() { + *expr = *if test.value { + conditional.cons.clone() + } else { + conditional.alt.clone() + }; + } + } + } + + fn visit_mut_prop(&mut self, prop: &mut ast::Prop) { + if let ast::Prop::Shorthand(ident) = prop { + if let Some(value) = self.replacement(&ast::Expr::Ident(ident.clone())) { + *prop = ast::Prop::KeyValue(ast::KeyValueProp { + key: ast::PropName::Ident(ident.clone().into()), + value, + }); + return; + } + } + prop.visit_mut_children_with(self); + } + + fn visit_mut_ts_type(&mut self, _: &mut ast::TsType) {} +} + +struct ClearContexts; +impl VisitMut for ClearContexts { + fn visit_mut_syntax_context(&mut self, ctxt: &mut SyntaxContext) { + *ctxt = SyntaxContext::empty(); + } + fn visit_mut_span(&mut self, _: &mut swc_common::Span) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_keys_and_expressions() { + for (key, value) in [ + ("A-B", "1"), + ("a['b']", "1"), + ("A", "foo()"), + ("A", "1); evil(); (2"), + ] { + assert!(Defines::parse(&BTreeMap::from([(key.into(), value.into())])).is_err()); + } + Defines::parse(&BTreeMap::from([( + "a.b".into(), + r#"{"models":[1,true,null,"x"]}"#.into(), + )])) + .unwrap(); + } + + #[test] + fn replaces_reads_and_shorthand_but_preserves_bindings_and_writes() { + let defines = Defines::parse(&BTreeMap::from([ + ("VERSION".into(), "\"1.18.30\"".into()), + ("process.env.X".into(), "false".into()), + ])) + .unwrap(); + let module = crate::parse_typescript("console.log(VERSION, { VERSION }, typeof VERSION, process.env.X); function f(VERSION: string, process: any) { return [VERSION, process.env.X]; } VERSION = 'write';", "test.ts").unwrap(); + let transformed = defines.apply(&module).unwrap(); + let dump = format!("{transformed:?}"); + assert_eq!(dump.matches("value: \"1.18.30\"").count(), 2); + assert!(dump.contains("value: \"string\"")); + assert_eq!(dump.matches("value: false").count(), 1); + assert!(!format!("{module:?}").contains("1.18.30")); + } + + #[test] + fn ambient_declarations_are_not_runtime_bindings() { + let defines = Defines::parse(&BTreeMap::from([("VERSION".into(), "42".into())])).unwrap(); + let module = crate::parse_typescript( + "declare const VERSION: number; console.log(VERSION);", + "test.ts", + ) + .unwrap(); + let dump = format!("{:?}", defines.apply(&module).unwrap()); + assert!(dump.contains("value: 42.0"), "{dump}"); + } + + #[test] + fn computed_reads_are_replaced_without_dropping_typeof_effects() { + let defines = Defines::parse(&BTreeMap::from([ + ("process.env.X".into(), "'defined'".into()), + ("KEY".into(), "'key'".into()), + ])) + .unwrap(); + let module = crate::parse_typescript( + "console.log(process['env']['X'], typeof { x: sideEffect() }); object[KEY] = 1; process.env.X = 'write';", + "test.ts", + ).unwrap(); + let dump = format!("{:?}", defines.apply(&module).unwrap()); + assert_eq!(dump.matches("value: \"defined\"").count(), 1); + assert_eq!(dump.matches("value: \"key\"").count(), 1); + assert!(dump.contains("sideEffect")); + assert!(dump.contains("typeof"), "{dump}"); + } +} diff --git a/crates/perry-parser/src/lib.rs b/crates/perry-parser/src/lib.rs index 28c05f39e2..0f8380dbec 100644 --- a/crates/perry-parser/src/lib.rs +++ b/crates/perry-parser/src/lib.rs @@ -11,6 +11,8 @@ use swc_ecma_ast::{Module, ModuleItem, Program, Script}; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, Syntax, TsSyntax}; use swc_ecma_visit::{VisitMut, VisitMutWith}; +pub mod defines; + // Re-export AST types for consumers that need to inspect the AST pub use swc_ecma_ast; diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 997364a9ea..68c646e5e9 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -14,6 +14,8 @@ use crate::object::{js_object_alloc, js_object_get_field_by_name, js_object_set_ use crate::string::js_string_from_bytes; use crate::value::{js_nanbox_pointer, JSValue, TAG_FALSE, TAG_NULL, TAG_TRUE, TAG_UNDEFINED}; +mod import_meta_resolve; + #[cfg(test)] mod dynamic_import_tests; diff --git a/crates/perry-runtime/src/module_require/import_meta_resolve.rs b/crates/perry-runtime/src/module_require/import_meta_resolve.rs new file mode 100644 index 0000000000..7c2dc155eb --- /dev/null +++ b/crates/perry-runtime/src/module_require/import_meta_resolve.rs @@ -0,0 +1,233 @@ +//! Runtime filesystem resolution for import.meta.resolve(specifier[, parent]). +//! Resolving a module does not load or execute it. + +use super::*; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +fn file_or_directory(path: &Path, depth: usize) -> Option { + if depth > 16 { + return None; + } + if path.is_file() { + return std::fs::canonicalize(path).ok(); + } + if path.extension().is_none() { + for ext in ["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "node"] { + let candidate = path.with_extension(ext); + if candidate.is_file() { + return std::fs::canonicalize(candidate).ok(); + } + } + } + if !path.is_dir() { + return None; + } + if let Some(package) = manifest(path) { + if let Some(main) = package + .get("module") + .or_else(|| package.get("main")) + .and_then(Value::as_str) + { + if let Some(found) = file_or_directory(&path.join(main), depth + 1) { + return Some(found); + } + } + } + for ext in ["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "node"] { + let candidate = path.join(format!("index.{ext}")); + if candidate.is_file() { + return std::fs::canonicalize(candidate).ok(); + } + } + None +} + +fn manifest(path: &Path) -> Option { + serde_json::from_slice(&std::fs::read(path.join("package.json")).ok()?).ok() +} + +fn conditional_target(value: &Value) -> Option<&str> { + match value { + Value::String(target) => Some(target), + Value::Array(targets) => targets.iter().find_map(conditional_target), + Value::Object(conditions) => ["bun", "import", "node", "default"] + .iter() + .find_map(|condition| conditions.get(*condition).and_then(conditional_target)), + _ => None, + } +} + +fn export_target(exports: &Value, key: &str) -> Option { + if let Value::Object(map) = exports { + if map.keys().any(|key| key.starts_with('.')) { + if let Some(value) = map.get(key) { + return conditional_target(value).map(str::to_owned); + } + let mut patterns: Vec<_> = map + .iter() + .filter_map(|(pattern, value)| { + let (prefix, suffix) = pattern.split_once('*')?; + let matched = key.strip_prefix(prefix)?.strip_suffix(suffix)?; + Some((prefix.len(), suffix.len(), value, matched)) + }) + .collect(); + patterns.sort_by_key(|(prefix, suffix, _, _)| std::cmp::Reverse((*prefix, *suffix))); + let (_, _, value, matched) = patterns.first()?; + return conditional_target(value).map(|target| target.replace('*', matched)); + } + } + (key == ".") + .then(|| conditional_target(exports)) + .flatten() + .map(str::to_owned) +} + +fn package_entry(root: &Path, subpath: &str) -> Option { + if let Some(package) = manifest(root) { + if let Some(exports) = package.get("exports") { + let key = if subpath.is_empty() { + ".".to_owned() + } else { + format!("./{subpath}") + }; + let target = export_target(exports, &key)?; + if !target.starts_with("./") + || target + .split('/') + .any(|part| part == ".." || part == "node_modules") + { + return None; + } + return file_or_directory(&root.join(target), 0); + } + } + file_or_directory(&root.join(subpath), 0) +} + +fn resolve_disk(specifier: &str, parent: &Path) -> Option { + let path = Path::new(specifier); + if path.is_absolute() { + return file_or_directory(path, 0); + } + if specifier.starts_with('.') { + return file_or_directory(&parent.join(path), 0); + } + if specifier.is_empty() { + return None; + } + let split = if specifier.starts_with('@') { + let scope = specifier.find('/')?; + specifier[scope + 1..] + .find('/') + .map(|index| scope + 1 + index) + } else { + specifier.find('/') + }; + let (name, subpath) = split + .map(|index| (&specifier[..index], &specifier[index + 1..])) + .unwrap_or((specifier, "")); + for ancestor in parent.ancestors() { + let root = ancestor.join("node_modules").join(name); + if root.is_dir() { + return package_entry(&root, subpath); + } + } + None +} + +fn decode_path(value: &str) -> PathBuf { + if value.starts_with("file:") { + let decoded = + crate::url::node_compat::js_url_file_url_to_path(string_value(value), undefined()); + PathBuf::from(value_to_string(decoded, "parent")) + } else { + PathBuf::from(value) + } +} + +#[no_mangle] +pub extern "C" fn js_import_meta_resolve(specifier: f64, parent: f64, fallback: f64) -> f64 { + // Read all JS inputs before URL conversion can allocate/collect. + let specifier = value_to_string(specifier, "specifier"); + let explicit = !JSValue::from_bits(parent.to_bits()).is_undefined(); + let parent = value_to_string(if explicit { parent } else { fallback }, "parent"); + if let Some(name) = supported_require_builtin(&specifier) { + return string_value(&format!( + "node:{}", + name.strip_prefix("node:").unwrap_or(name) + )); + } + let mut base = decode_path(&parent); + if !explicit + || (parent.starts_with("file:") && !parent.ends_with('/') && !base.is_dir()) + || base.is_file() + { + base.pop(); + } + if !base.is_absolute() { + base = std::env::current_dir().unwrap_or_default().join(base); + } + let resolved = if specifier.starts_with("file:") { + file_or_directory(&decode_path(&specifier), 0) + } else { + resolve_disk(&specifier, &base) + }; + let Some(path) = resolved else { + crate::fs::validate::throw_error_with_code( + &format!("Cannot find module '{specifier}' from '{parent}'"), + "ERR_MODULE_NOT_FOUND", + ); + }; + let path = path.to_string_lossy(); + let path = if let Some(unc) = path.strip_prefix(r"\\?\UNC\") { + format!(r"\\{unc}") + } else { + path.strip_prefix(r"\\?\").unwrap_or(&path).to_owned() + }; + string_value(&crate::url::node_compat::path_to_file_url_string( + &path, + cfg!(windows), + )) +} + +extern "C" fn resolve_closure(closure: *mut ClosureHeader, specifier: f64, parent: f64) -> f64 { + let fallback = js_closure_get_capture_f64(closure, 0); + js_import_meta_resolve(specifier, parent, fallback) +} + +#[no_mangle] +pub extern "C" fn js_import_meta_resolve_value(fallback: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let fallback = scope.root_nanbox_f64(fallback); + let (closure, value) = named_closure(resolve_closure as *const u8, 2, 1, "resolve"); + js_closure_set_capture_f64(closure, 0, fallback.get_nanbox_f64()); + value +} + +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_RESOLVE: extern "C" fn(f64, f64, f64) -> f64 = js_import_meta_resolve; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_RESOLVE_VALUE: extern "C" fn(f64) -> f64 = js_import_meta_resolve_value; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn export_subpaths_conditions_patterns_and_blocking() { + let exports = serde_json::json!({".": {"import": "./esm.js", "require": "./cjs.js"}, "./worker": "./parser.worker.js", "./assets/*": "./dist/*", "./private": null}); + assert_eq!(export_target(&exports, "."), Some("./esm.js".into())); + assert_eq!( + export_target(&exports, "./worker"), + Some("./parser.worker.js".into()) + ); + assert_eq!( + export_target(&exports, "./assets/tree.wasm"), + Some("./dist/tree.wasm".into()) + ); + assert_eq!(export_target(&exports, "./private"), None); + assert_eq!(export_target(&exports, "./missing"), None); + } +} diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 54f8bd0a59..9f632a997c 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -20,6 +20,7 @@ mod bootstrap; mod build_cache; mod bundle_apple; mod bundle_ios; +mod defines; // `pub(crate)` so `commands::deps` can reuse `cjs_wrap::detect`'s // comment/string masker for its source scans (D005) instead of duplicating a // subtle scanner. diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index f1be2aa7e0..8f010880ad 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -734,6 +734,7 @@ impl BuildCacheProbe { runtime_inputs: &[PathBuf], ) -> Result { let mut source_paths = ctx.native_modules.keys().cloned().collect::>(); + source_paths.extend(ctx.resolve_inputs.iter().cloned()); // Graph-discovered assets (including `/$bunfs/root/...` literals) are // not necessarily modules. Fingerprint their source bytes alongside // modules so changing an embedded file cannot reuse a stale binary. @@ -932,6 +933,14 @@ fn entry_uses_precompile(input: &Path) -> bool { fn args_key(args: &CompileArgs, output_path: &Path, project_root: &Path) -> String { let mut hasher = Sha256::new(); hash_field(&mut hasher, "args-debug", &format!("{args:?}")); + match super::defines::load(project_root, &args.define) { + Ok(defines) => hash_field( + &mut hasher, + "defines", + &serde_json::to_string(&defines).unwrap(), + ), + Err(error) => hash_field(&mut hasher, "defines-error", &error.to_string()), + } hash_field(&mut hasher, "input", &absolute_identity(&args.input)); hash_field(&mut hasher, "output", &absolute_identity(output_path)); if let Some(root) = &args.bunfs_root { @@ -1052,7 +1061,13 @@ fn config_inputs_for( cache_root: &Path, ) -> BTreeSet { let mut out = BTreeSet::new(); - for name in ["package.json", "perry.toml", "tsconfig.json", "perry.lock"] { + for name in [ + "package.json", + "perry.json", + "perry.toml", + "tsconfig.json", + "perry.lock", + ] { let path = project_root.join(name); if path.exists() { out.insert(path); @@ -1066,7 +1081,7 @@ fn config_inputs_for( let mut dir = PathBuf::from(&source.path); dir.pop(); loop { - for name in ["package.json", "perry.toml"] { + for name in ["package.json", "perry.json", "perry.toml"] { let candidate = dir.join(name); if candidate.exists() { out.insert(candidate); diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 8247d97149..8343aa43fd 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -37,6 +37,7 @@ mod eval_worker; mod feature_detect; mod import_helpers; mod import_meta_require; +mod import_meta_resolve; mod json_module; mod native_addon; mod parse_error; @@ -535,6 +536,10 @@ fn collect_module_one( } }, }; + let defined_module = ctx.parsed_defines.apply(ast_module); + let ast_module = defined_module.as_ref().unwrap_or(ast_module); + let resolved_module = import_meta_resolve::resolve_static(ast_module, &canonical, ctx)?; + let ast_module = resolved_module.as_ref().unwrap_or(ast_module); let file_loader_sources = file_loader_import_sources(ast_module); let source_file_path = canonical.to_string_lossy().to_string(); diff --git a/crates/perry/src/commands/compile/collect_modules/import_helpers.rs b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs index 0f775ffa9b..ae39fbfacf 100644 --- a/crates/perry/src/commands/compile/collect_modules/import_helpers.rs +++ b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs @@ -145,10 +145,12 @@ pub(super) fn collect_js_module_imports(file_path: &std::path::Path, source: &st // so the most common case (top-level package brings in submodules) // is covered. Inside a package's `node_modules` tree, all // sibling imports are relative-path anyway. - if !(super::super::resolve::is_relative_specifier(&spec) || spec.starts_with('/')) { + if !(super::super::resolve::is_relative_specifier(&spec) + || super::super::resolve::is_absolute_specifier(&spec)) + { continue; } - let resolved_path = if spec.starts_with('/') { + let resolved_path = if super::super::resolve::is_absolute_specifier(&spec) { super::super::resolve::resolve_absolute_import_paths(&spec) } else { super::super::resolve::resolve_relative_import_paths(&spec, file_path) @@ -214,7 +216,7 @@ fn source_visible_resolved_path( importer_path: &Path, canonical_path: &Path, ) -> PathBuf { - let resolved = if import_source.starts_with('/') { + let resolved = if super::super::resolve::is_absolute_specifier(import_source) { super::super::resolve::resolve_absolute_import_paths(import_source) } else if super::super::resolve::is_relative_specifier(import_source) { super::super::resolve::resolve_relative_import_paths(import_source, importer_path) diff --git a/crates/perry/src/commands/compile/collect_modules/import_meta_resolve.rs b/crates/perry/src/commands/compile/collect_modules/import_meta_resolve.rs new file mode 100644 index 0000000000..d9b9b77f1a --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/import_meta_resolve.rs @@ -0,0 +1,125 @@ +//! Resolve literal module/asset URLs without importing or executing the target. + +use super::super::{cached_resolve_import, CompilationContext}; +use anyhow::Result; +use std::path::{Path, PathBuf}; +use swc_ecma_ast as ast; +use swc_ecma_visit::{Visit, VisitMut, VisitMutWith, VisitWith}; + +fn is_resolve(expr: &ast::Expr) -> bool { + match expr { + ast::Expr::Paren(paren) => is_resolve(&paren.expr), + ast::Expr::Member(member) => { + matches!(member.obj.as_ref(), ast::Expr::MetaProp(meta) if meta.kind == ast::MetaPropKind::ImportMeta) + && match &member.prop { + ast::MemberProp::Ident(name) => name.sym == "resolve", + ast::MemberProp::Computed(key) => { + matches!(key.expr.as_ref(), ast::Expr::Lit(ast::Lit::Str(name)) if name.value.as_str() == Some("resolve")) + } + _ => false, + } + } + _ => false, + } +} + +pub(super) fn resolve_static( + module: &ast::Module, + importer: &Path, + ctx: &mut CompilationContext, +) -> Result> { + struct Find(bool); + impl Visit for Find { + fn visit_call_expr(&mut self, call: &ast::CallExpr) { + self.0 |= matches!(&call.callee, ast::Callee::Expr(expr) if is_resolve(expr)); + call.visit_children_with(self); + } + } + let mut find = Find(false); + module.visit_with(&mut find); + if !find.0 { + return Ok(None); + } + let mut result = module.clone(); + result.visit_mut_with(&mut Resolve { importer, ctx }); + Ok(Some(result)) +} + +struct Resolve<'a> { + importer: &'a Path, + ctx: &'a mut CompilationContext, +} +impl VisitMut for Resolve<'_> { + fn visit_mut_expr(&mut self, expr: &mut ast::Expr) { + expr.visit_mut_children_with(self); + let ast::Expr::Call(call) = expr else { return }; + if !matches!(&call.callee, ast::Callee::Expr(expr) if is_resolve(expr)) + || call.args.is_empty() + || call.args.len() > 2 + || call.args.iter().any(|arg| arg.spread.is_some()) + { + return; + } + let ast::Expr::Lit(ast::Lit::Str(spec)) = call.args[0].expr.as_ref() else { + return; + }; + let Some(spec) = spec.value.as_str() else { + return; + }; + let importer = if let Some(parent) = call.args.get(1) { + let ast::Expr::Lit(ast::Lit::Str(parent)) = parent.expr.as_ref() else { + return; + }; + let Some(parent) = parent.value.as_str() else { + return; + }; + if parent.starts_with("file:") { + let Some(path) = url::Url::parse(parent) + .ok() + .and_then(|url| url.to_file_path().ok()) + else { + return; + }; + if parent.ends_with('/') || path.is_dir() { + path.join("__perry_resolve__.ts") + } else { + path + } + } else { + let path = PathBuf::from(parent); + if !path.is_absolute() { + return; + } + if path.is_file() { + path + } else { + path.join("__perry_resolve__.ts") + } + } + } else { + self.importer.to_path_buf() + }; + let resolved = if spec.starts_with("file:") { + url::Url::parse(spec) + .ok() + .and_then(|url| url.to_file_path().ok()) + .filter(|path| path.is_file()) + } else { + cached_resolve_import(spec, &importer, self.ctx) + .map(|(path, _)| path) + .filter(|path| path.is_file()) + }; + let Some(path) = resolved.and_then(|path| std::fs::canonicalize(path).ok()) else { + return; + }; + let Ok(url) = url::Url::from_file_path(&path) else { + return; + }; + self.ctx.resolve_inputs.insert(path); + *expr = ast::Expr::Lit(ast::Lit::Str(ast::Str { + span: call.span, + value: url.to_string().into(), + raw: None, + })); + } +} diff --git a/crates/perry/src/commands/compile/defines.rs b/crates/perry/src/commands/compile/defines.rs new file mode 100644 index 0000000000..f5d911bdc2 --- /dev/null +++ b/crates/perry/src/commands/compile/defines.rs @@ -0,0 +1,80 @@ +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; + +/// Existing package.json literals < perry.json JS expressions < repeated CLI flags. +/// Read before the build-cache probe so creating a config also invalidates it. +pub(super) fn load(root: &Path, cli: &[String]) -> Result> { + let mut values = BTreeMap::new(); + if let Some(path) = root + .ancestors() + .map(|dir| dir.join("package.json")) + .find(|p| p.is_file()) + { + let package: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?; + if let Some(defines) = package + .get("perry") + .and_then(|p| p.get("define")) + .and_then(|d| d.as_object()) + { + for (key, value) in defines { + values.insert(key.clone(), value.to_string()); + } + } + } + if let Some(path) = root + .ancestors() + .map(|dir| dir.join("perry.json")) + .find(|p| p.is_file()) + { + let config: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?) + .with_context(|| format!("invalid {}", path.display()))?; + if let Some(defines) = config.get("define") { + let Some(defines) = defines.as_object() else { + bail!("perry.json define must be an object"); + }; + for (key, value) in defines { + values.insert( + key.clone(), + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()), + ); + } + } + } + for value in cli { + let Some((key, expression)) = value.split_once('=') else { + bail!("--define expects NAME=EXPR, got {value:?}"); + }; + values.insert(key.to_owned(), expression.to_owned()); + } + Ok(values) +} + +pub(super) fn object_hash(hir_hash: u64, defines: &BTreeMap) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + hir_hash.hash(&mut hasher); + defines.hash(&mut hasher); + hasher.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_key_includes_even_defines_not_read_by_this_module() { + let empty = BTreeMap::new(); + let mut values = BTreeMap::from([("VERSION".into(), "'one'".into())]); + let first = object_hash(42, &values); + assert_ne!(first, object_hash(42, &empty)); + values.insert("VERSION".into(), "'two'".into()); + assert_ne!(first, object_hash(42, &values)); + values.insert("VERSION".into(), "'one'".into()); + assert_eq!(first, object_hash(42, &values)); + } +} diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index b52d96d8ee..87b1eba8b6 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -1174,7 +1174,7 @@ pub(super) fn declaration_sidecar_for_resolved_import( return canonical_existing_declaration(resolved_path.to_path_buf()); } - if !(is_relative_specifier(import_source) || import_source.starts_with('/')) { + if !(is_relative_specifier(import_source) || is_absolute_specifier(import_source)) { let (package_name, subpath) = parse_package_specifier(import_source); if let Some(package_dir) = package_dir_for_resolved_path(resolved_path, &package_name) { if let Some(sidecar) = resolve_package_declaration_entry( @@ -1283,7 +1283,7 @@ pub(super) fn resolve_relative_import_paths( } pub(super) fn resolve_absolute_import_paths(import_source: &str) -> Option { - if !import_source.starts_with('/') { + if !is_absolute_specifier(import_source) { return None; } let source_path = resolve_with_extensions(&PathBuf::from(import_source))?; @@ -1365,6 +1365,11 @@ pub(super) fn attempted_relative_import_path( Some(normalize_path_lexically(&absolute)) } +pub(super) fn is_absolute_specifier(import_source: &str) -> bool { + // Worker entries and file URLs also produce drive-letter or UNC paths. + import_source.starts_with('/') || Path::new(import_source).is_absolute() +} + /// True for ECMAScript relative-import specifiers. Besides the obvious `./x` /// and `../x`, the bare `"."` and `".."` are also relative — they resolve to /// the current / parent **directory**'s `index` file. `@tanstack/table-core`'s @@ -1517,7 +1522,7 @@ pub(super) fn resolve_import_with_bunfs( } // Handle absolute paths - if import_source.starts_with('/') { + if is_absolute_specifier(import_source) { let resolved = PathBuf::from(import_source); if let Some(path) = resolve_with_extensions(&resolved) { let canonical = path.canonicalize().ok()?; diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 3a1900c400..ae00b3fb64 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -814,6 +814,8 @@ pub fn run_with_parse_cache( // otherwise enable debug locations or symbols. ctx.debug_symbols = args.debug_symbols || opt_report_format == Some(OptReportFormat::Text); + ctx.expression_defines = super::defines::load(&project_root, &args.define)?; + ctx.parsed_defines = perry_parser::defines::Defines::parse(&ctx.expression_defines)?; let build_cache_probe = BuildCacheProbe::new(&args, &project_root, &ctx.cache_root, &ctx.cache_dir); let mut build_cache_stats = build_cache_probe.probe(); @@ -5541,7 +5543,7 @@ pub fn run_with_parse_cache( let (cache_key, hir_hash_for_diag) = if object_cache.is_enabled() { let hir_hash = perry_hir::stable_hash::hash_module(hir_module); ( - Some(compute_object_cache_key(&opts, hir_hash, perry_version)), + Some(compute_object_cache_key(&opts, super::defines::object_hash(hir_hash, &ctx.expression_defines), perry_version)), Some(hir_hash), ) } else { diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 28af24b81a..5c82754a6f 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -88,6 +88,10 @@ pub struct CompileArgs { #[arg(short, long)] pub output: Option, + /// Replace an unbound identifier or dotted name with a JS expression (repeatable) + #[arg(long, value_name = "NAME=EXPR")] + pub define: Vec, + /// Keep intermediate files (for debugging) #[arg(long)] pub keep_intermediates: bool, @@ -1133,6 +1137,9 @@ pub struct CompilationContext { /// `NODE_ENV → "production"` default applied to `node_modules` code unless /// overridden. Keyed by the full `process.env.` string. pub define: HashMap, + pub expression_defines: BTreeMap, + pub parsed_defines: perry_parser::defines::Defines, + pub resolve_inputs: BTreeSet, /// #5247 (CJS-wrap coordinate skew): for each CommonJS module rewritten by /// `cjs_wrap::wrap_commonjs_for_target`, the final wrapped source /// text plus the number of newline characters the injected wrapper prefix @@ -1312,6 +1319,9 @@ impl CompilationContext { deferred_refusals: Vec::new(), side_effects_cache: HashMap::new(), define: HashMap::new(), + expression_defines: BTreeMap::new(), + parsed_defines: perry_parser::defines::Defines::default(), + resolve_inputs: BTreeSet::new(), cjs_wrap_debug_sources: HashMap::new(), debug_symbols: false, } diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 5b28e33f3a..921aa6d9a8 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -281,6 +281,7 @@ fn build_once( use_color: bool, ) -> Result<()> { let args = CompileArgs { + define: Vec::new(), input: input.to_path_buf(), output: Some(output.to_path_buf()), keep_intermediates: false, diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index c7fc514104..b38342a363 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -193,6 +193,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> // Local compile path let compile_args = CompileArgs { + define: Vec::new(), input: input.clone(), output: Some(PathBuf::from(&app_name)), keep_intermediates: false, diff --git a/crates/perry/tests/issue_10101_defines_resolve.rs b/crates/perry/tests/issue_10101_defines_resolve.rs new file mode 100644 index 0000000000..68a26556b8 --- /dev/null +++ b/crates/perry/tests/issue_10101_defines_resolve.rs @@ -0,0 +1,224 @@ +use std::path::Path; +use std::process::{Command, Output}; + +fn compile(root: &Path, args: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_perry")); + // LLVM's statepoint pass does not support Windows catchpad EH (#7354). + if cfg!(windows) { + command.env("PERRY_RS4GC", "0"); + } + command + .current_dir(root) + .args(["compile", "main.ts", "-o", "app.exe"]) + .args(args) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env_remove("PERRY_NO_CACHE") + .env_remove("PERRY_DISABLE_BUILD_CACHE") + .output() + .expect("compile") +} + +fn success(output: &Output) -> String { + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n") +} + +fn run(root: &Path, args: &[&str]) -> String { + success(&compile(root, args)); + success( + &Command::new(root.join("app.exe")) + .current_dir(root) + .output() + .unwrap(), + ) +} + +#[test] +fn defines_fold_guards_and_invalidate_both_caches() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("main.ts"), + r#" +declare const OPENCODE_VERSION: string; +console.log(typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"); +console.log(typeof OPENCODE_MODELS_DEV === "undefined" ? "fetch" : OPENCODE_MODELS_DEV.model); +function local(OPENCODE_VERSION: string) { return OPENCODE_VERSION; } +console.log(local("shadow")); +"#, + ) + .unwrap(); + assert_eq!(run(root, &[]), "local\nfetch\nshadow\n"); + let args = [ + "--define", + "OPENCODE_VERSION=\"1.18.30\"", + "--define", + "OPENCODE_MODELS_DEV={\"model\":\"snapshot\"}", + ]; + assert_eq!(run(root, &args), "1.18.30\nsnapshot\nshadow\n"); + // Same output path exercises the whole-build probe as well as object reuse. + assert_eq!(run(root, &args), "1.18.30\nsnapshot\nshadow\n"); + assert_eq!( + run(root, &["--define", "OPENCODE_VERSION=\"1.18.31\""]), + "1.18.31\nfetch\nshadow\n" + ); + assert_eq!(run(root, &[]), "local\nfetch\nshadow\n"); +} + +#[test] +fn json_expressions_and_cli_override_legacy_package_literals() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("main.ts"), + "console.log(process.env.FLAVOR, VERSION, COUNT, ALIAS);", + ) + .unwrap(); + std::fs::write(root.join("package.json"), r#"{"perry":{"define":{"process.env.FLAVOR":"legacy","VERSION":"legacy","COUNT":1,"ALIAS":0}}}"#).unwrap(); + assert_eq!(run(root, &[]), "legacy legacy 1 0\n"); + std::fs::write(root.join("perry.json"), r#"{"define":{"process.env.FLAVOR":"'config'","VERSION":"'json'","COUNT":"2","ALIAS":"Math.PI"}}"#).unwrap(); + assert!(run( + root, + &["--define", "VERSION='first'", "--define", "VERSION='cli'"] + ) + .starts_with("config cli 2 3.14159")); + std::fs::write( + root.join("perry.json"), + r#"{"define":{"VERSION":"'changed'"}}"#, + ) + .unwrap(); + assert_eq!(run(root, &[]), "legacy changed 1 0\n"); +} + +#[test] +fn invalid_defines_are_diagnosed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("main.ts"), "console.log('ok');").unwrap(); + for value in ["MISSING_EQUALS", "BAD-NAME=1", "VALUE=run()", "VALUE="] { + let output = compile(dir.path(), &["--define", value, "--no-link"]); + assert!(!output.status.success(), "accepted {value}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("define")); + } +} + +#[test] +fn resolves_static_assets_and_runtime_installed_packages_with_directory_or_url_parent() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let package = root.join("node_modules/@fixture/core"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), r#"{"exports":{"./parser.worker":"./parser.worker.js","./tree-sitter.wasm":"./tree-sitter.wasm"}}"#).unwrap(); + std::fs::write( + package.join("parser.worker.js"), + "throw new Error('resolve must not execute');", + ) + .unwrap(); + std::fs::write(package.join("tree-sitter.wasm"), b"\0asm").unwrap(); + std::fs::write( + root.join("main.ts"), + r#" +console.log(import.meta.resolve("@fixture/core/parser.worker").endsWith("/parser.worker.js")); +console.log(import.meta.resolve("@fixture/core/tree-sitter.wasm").endsWith("/tree-sitter.wasm")); +const name = process.argv[2]; +const parent = process.argv[3]; +const resolved = import.meta.resolve(name, parent); +console.log(resolved.startsWith("file://"), resolved.endsWith("/entry%20space.js")); +const resolve = import.meta.resolve; +console.log(resolve(name, parent) === resolved); +console.log(resolve(process.argv[4]).endsWith("/default.js")); +try { resolve("missing-package", parent); } catch (error) { console.log(error.code); } +console.log(import.meta.resolve("node:fs")); +"#, + ) + .unwrap(); + success(&compile(root, &[])); + // Literal resolutions have become URLs in the executable, independent of + // the runtime package resolver. Resolving them did not execute their bodies. + std::fs::remove_dir_all(&package).unwrap(); + // This package exists only AFTER compilation, proving disk resolution is dynamic. + let parent = root.join("installed"); + let installed = parent.join("node_modules/x"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("package.json"), + r#"{"exports":{".":{"import":"./entry space.js","require":"./wrong.cjs"}}}"#, + ) + .unwrap(); + std::fs::write( + installed.join("entry space.js"), + "throw new Error('do not execute');", + ) + .unwrap(); + let default_package = root.join("node_modules/default-fixture"); + std::fs::create_dir_all(&default_package).unwrap(); + std::fs::write( + default_package.join("package.json"), + r#"{"main":"default.js"}"#, + ) + .unwrap(); + std::fs::write( + default_package.join("default.js"), + "throw new Error('do not execute');", + ) + .unwrap(); + for parent_arg in [ + parent.to_string_lossy().to_string(), + url::Url::from_directory_path(&parent).unwrap().to_string(), + url::Url::from_file_path(parent.join("caller.ts")) + .unwrap() + .to_string(), + ] { + let output = Command::new(root.join("app.exe")) + .args(["x", &parent_arg, "default-fixture"]) + .output() + .unwrap(); + assert_eq!( + success(&output), + "true\ntrue\ntrue true\ntrue\ntrue\nERR_MODULE_NOT_FOUND\nnode:fs\n" + ); + } +} + +#[test] +fn parent_url_and_defined_worker_entries_are_discovered() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("cli")).unwrap(); + std::fs::create_dir_all(root.join("tui")).unwrap(); + std::fs::write(root.join("tui/worker.ts"), "postMessage('worker-ready');").unwrap(); + std::fs::write(root.join("main.ts"), "import './cli/main';").unwrap(); + std::fs::write( + root.join("cli/main.ts"), + r#" +setTimeout(() => process.exit(2), 5000); +const worker = new Worker(new URL("../tui/worker.ts", import.meta.url)); +worker.onmessage = (event: any) => { console.log(event.data); process.exit(0); }; +"#, + ) + .unwrap(); + let discovered = success(&compile(root, &["--no-link"])); + assert!( + discovered.contains("3 native, 0 JavaScript"), + "{discovered}" + ); + std::fs::write(root.join("cli/main.ts"), r#" +setTimeout(() => process.exit(2), 5000); +const path = typeof OPENCODE_WORKER_PATH === "undefined" ? new URL("../tui/worker.ts", import.meta.url) : OPENCODE_WORKER_PATH; +const worker = new Worker(path); +worker.onmessage = (event: any) => { console.log(event.data); process.exit(0); }; +"#).unwrap(); + let define = format!( + "OPENCODE_WORKER_PATH={}", + serde_json::to_string(&root.join("tui/worker.ts").to_string_lossy()).unwrap() + ); + let discovered = success(&compile(root, &["--no-link", "--define", &define])); + assert!( + discovered.contains("3 native, 0 JavaScript"), + "{discovered}" + ); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 92630ae062..820fb792f1 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -4,6 +4,60 @@ Reference for the public `perry compile` flags plus Perry's global flags. The installed binary remains authoritative for feature-gated commands; use `perry --help` and `perry --help` to inspect that exact build. +## Build-time defines + +`--define NAME=EXPR` is repeatable. It replaces unbound identifier reads and +dotted names before lowering, including `typeof` guards, object shorthand, +and worker paths. Local variables and parameters with the same name retain +their normal meaning. Values are JSON expressions or identifier expressions; +quote a JavaScript string inside the shell argument: + +```sh +perry compile src/index.ts --define 'OPENCODE_VERSION="1.18.30"' \ + --define 'process.env.OPENTUI_LIBC="glibc"' +``` + +The nearest `perry.json` above the entry can hold the same expressions: + +```json +{ + "define": { + "OPENCODE_VERSION": "\"1.18.30\"", + "OPENCODE_CHANNEL": "\"latest\"", + "OPENCODE_MODELS_DEV": "{\"provider\":{\"models\":{}}}" + } +} +``` + +Strings in `perry.json` are JavaScript expressions, as in Bun's `define` map. +Existing `package.json` `perry.define` strings keep their literal-string +meaning. Precedence is package literals, then `perry.json`, then CLI flags; +the last CLI value wins. Both build and object caches include the effective map. + +`import.meta.resolve(specifier[, parent])` returns a `file://` URL for filesystem +targets and a `node:` specifier for supported builtins. Literal +module and asset specifiers resolve at compile time; dynamic specifiers resolve +from the calling module or an explicit directory/file URL at runtime, including +packages installed in that directory's `node_modules` after compilation. +Resolution does not execute the target module. Missing runtime targets throw +an error with code `ERR_MODULE_NOT_FOUND`. Static URLs refer to the resolved +files; deploy those files when the application reads their contents. + +For OpenCode v1.18.30, after installing its dependencies, run: + +```sh +MODELS_DEV_API_JSON=/path/to/models-snapshot.json \ + bun scripts/build_opencode.ts /path/to/opencode --perry /path/to/perry \ + --output ./opencode-native +``` + +The harness merges release defines into `packages/opencode/perry.json`, reads +the version from OpenCode's package, uses channel `latest`, imports upstream +`script/generate.ts` for the models snapshot, and supplies native worker source +entries. Use `--os linux --libc glibc` (or `musl`) for the eight Linux defines; +`--prepare-only` writes the configuration without compiling. Other OpenCode +compatibility requirements are tracked separately in issue #10107. + ## Global Flags Available on all commands: diff --git a/docs/src/getting-started/project-config.md b/docs/src/getting-started/project-config.md index 8b26764f18..5d464b359c 100644 --- a/docs/src/getting-started/project-config.md +++ b/docs/src/getting-started/project-config.md @@ -2,6 +2,10 @@ Perry projects use `perry.toml` and `package.json` for configuration. No special config file is required for basic usage, but larger projects benefit from Perry-specific settings. +Build-time identifier substitutions can also be configured in `perry.json`'s +`define` map. See [build-time defines](../cli/flags.md#build-time-defines) for +expression syntax, CLI overrides, and the OpenCode build harness. + > **Looking for the full perry.toml reference?** See [perry.toml Reference](../cli/perry-toml.md) for every field, section, platform option, and environment variable. ## Basic Setup diff --git a/scripts/build_opencode.test.ts b/scripts/build_opencode.test.ts new file mode 100644 index 0000000000..6223ab97c0 --- /dev/null +++ b/scripts/build_opencode.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; +import { opencodeDefines } from "./build_opencode"; + +test("OpenCode harness supplies the eight Linux defines from upstream inputs", async () => { + const checkout = await mkdtemp(join(tmpdir(), "perry-opencode-defines-")); + try { + const root = join(checkout, "packages/opencode"); + await mkdir(join(root, "script"), { recursive: true }); + const core = join(root, "node_modules/@opentui/core"); + await mkdir(core, { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ version: "1.18.30" })); + await writeFile(join(root, "script/generate.ts"), `export const modelsData = '{"provider":{"models":{}}}';`); + await writeFile(join(core, "package.json"), JSON.stringify({ exports: { "./parser.worker": "./worker.js" } })); + await writeFile(join(core, "worker.js"), "postMessage('ready');"); + const { defines } = await opencodeDefines(checkout, "linux", "musl"); + expect(Object.keys(defines)).toHaveLength(8); + expect(JSON.parse(defines.OPENCODE_VERSION)).toBe("1.18.30"); + expect(JSON.parse(defines.OPENCODE_CHANNEL)).toBe("latest"); + expect(JSON.parse(defines.OPENCODE_MODELS_DEV)).toEqual({ provider: { models: {} } }); + expect(JSON.parse(defines.FFF_LIBC)).toBe("musl"); + expect(JSON.parse(defines.OPENCODE_LIBC)).toBe("musl"); + expect(JSON.parse(defines["process.env.OPENTUI_LIBC"])).toBe("musl"); + expect(isAbsolute(JSON.parse(defines.OPENCODE_WORKER_PATH))).toBe(true); + expect(JSON.parse(defines.OTUI_TREE_SITTER_WORKER_PATH)).toBe(join(core, "worker.js")); + const mac = await opencodeDefines(checkout, "darwin", "glibc"); + expect(mac.defines.OPENCODE_LIBC).toBe("undefined"); + expect(mac.defines["process.env.OPENTUI_LIBC"]).toBeUndefined(); + expect(JSON.parse(mac.defines.FFF_LIBC)).toBe("gnu"); + const configPath = join(root, "perry.json"); + await writeFile(configPath, JSON.stringify({ other: true, define: { CUSTOM: "42", ...defines } })); + const child = Bun.spawn([ + process.execPath, fileURLToPath(new URL("./build_opencode.ts", import.meta.url)), + checkout, "--prepare-only", "--os", "darwin", + ], { stdout: "pipe", stderr: "pipe" }); + expect(await child.exited).toBe(0); + const config = await Bun.file(configPath).json(); + expect(config.other).toBe(true); + expect(config.define.CUSTOM).toBe("42"); + expect(config.define.OPENCODE_LIBC).toBe("undefined"); + expect(config.define["process.env.OPENTUI_LIBC"]).toBeUndefined(); + } finally { + await rm(checkout, { recursive: true, force: true }); + } +}); diff --git a/scripts/build_opencode.ts b/scripts/build_opencode.ts new file mode 100644 index 0000000000..23dd694721 --- /dev/null +++ b/scripts/build_opencode.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env bun +// Build the unmodified OpenCode source tree with its release-time constants. +import { parseArgs } from "node:util"; +import { resolve, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +export async function opencodeDefines(checkout: string, os: string, libc: string) { + const root = join(resolve(checkout), "packages/opencode"); + const { version } = await Bun.file(join(root, "package.json")).json(); + if (typeof version !== "string") throw new Error("OpenCode package.json has no version"); + // Upstream honors MODELS_DEV_API_JSON for a pinned/offline snapshot and + // otherwise fetches OPENCODE_MODELS_URL/api.json, exactly as its build does. + const cwd = process.cwd(); + let modelsData: string; + try { + ({ modelsData } = await import(pathToFileURL(join(root, "script/generate.ts")).href)); + } finally { + process.chdir(cwd); // upstream generate.ts changes cwd + } + JSON.parse(modelsData); // reject a failed/non-JSON snapshot before compiling + const defines: Record = { + FFF_LIBC: JSON.stringify(libc === "musl" ? "musl" : "gnu"), + OPENCODE_VERSION: JSON.stringify(version), + OPENCODE_MODELS_DEV: modelsData, + // Perry compiles these source entries into native Worker entry functions. + OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(Bun.resolveSync("@opentui/core/parser.worker", root)), + OPENCODE_WORKER_PATH: JSON.stringify(join(root, "src/cli/tui/worker.ts")), + OPENCODE_CHANNEL: JSON.stringify("latest"), + OPENCODE_LIBC: os === "linux" ? JSON.stringify(libc) : "undefined", + }; + if (os === "linux") defines["process.env.OPENTUI_LIBC"] = JSON.stringify(libc); + return { root, version, defines }; +} + +if (import.meta.main) { + const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), allowPositionals: true, + options: { + perry: { type: "string", default: "perry" }, + output: { type: "string", default: "opencode-native" }, + os: { type: "string", default: process.platform }, + libc: { type: "string", default: "glibc" }, + target: { type: "string" }, + "prepare-only": { type: "boolean", default: false }, + }, + }); + if (positionals.length !== 1 || !["glibc", "musl"].includes(values.libc)) { + throw new Error("Usage: bun scripts/build_opencode.ts [--perry binary] [--output binary] [--os linux --libc glibc|musl] [--target target] [--prepare-only]"); + } + const output = resolve(values.output); + const { root, version, defines } = await opencodeDefines(positionals[0], values.os, values.libc); + const configFile = Bun.file(join(root, "perry.json")); + const config = await configFile.exists() ? await configFile.json() : {}; + const merged = { ...config.define, ...defines }; + // Reusing a Linux checkout for another target must remove the Linux-only flag. + if (values.os !== "linux") delete merged["process.env.OPENTUI_LIBC"]; + await Bun.write(configFile, JSON.stringify({ ...config, define: merged }, null, 2) + "\n"); + console.log(`Prepared OpenCode ${version}, channel latest (${Object.keys(defines).length} defines)`); + if (!values["prepare-only"]) { + const command = [values.perry, "compile", "src/index.ts", "--platform", "bun", "--output", output]; + if (values.target) command.push("--target", values.target); + const child = Bun.spawn(command, { cwd: root, stdin: "inherit", stdout: "inherit", stderr: "inherit" }); + process.exit(await child.exited); + } +} From 23492e0e5b1c76b00aa3c318b77b154ee0f88f52 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 14:56:29 +0200 Subject: [PATCH 29/36] docs: number changelog fragment for PR 10129 (cherry picked from commit 72a44e944ea048eb615dadc4aea604b501a3daa4) --- ...01-build-defines-resolve.md => 10129-build-defines-resolve.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10101-build-defines-resolve.md => 10129-build-defines-resolve.md} (100%) diff --git a/changelog.d/10101-build-defines-resolve.md b/changelog.d/10129-build-defines-resolve.md similarity index 100% rename from changelog.d/10101-build-defines-resolve.md rename to changelog.d/10129-build-defines-resolve.md From 2881c12a61bccb1b78dbac2fb39812430730b409 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:47:01 +0200 Subject: [PATCH 30/36] fix(runtime): add ConPTY and keep PTY/process input responsive (cherry picked from commit 6f9faee8d5637524d0885c29ce4b2a6dc181ebee) --- changelog.d/8512-native-pty-process.md | 3 + crates/perry-api-manifest/src/entries.rs | 2 + .../perry-api-manifest/src/entries/part_3.rs | 2 + .../src/lower_call/native_module_dispatch.rs | 2 +- crates/perry-codegen/src/nm_install.rs | 2 +- crates/perry-runtime/src/child_process/mod.rs | 2 +- .../src/child_process/reactor.rs | 174 +----------- .../child_process/reactor/lifecycle_tests.rs | 228 ++++++++++++++++ .../src/child_process/reactor/stdin.rs | 205 ++++++++++++++ .../src/child_process/windows_fork.rs | 6 +- crates/perry-runtime/src/gc/mod.rs | 2 +- crates/perry-runtime/src/lib.rs | 2 +- .../perry-runtime/src/object/native_module.rs | 2 +- .../src/object/native_module_registry.rs | 2 +- crates/perry-runtime/src/pty/mod.rs | 105 ++++--- crates/perry-runtime/src/pty/native.rs | 166 +++-------- crates/perry-runtime/src/pty/reactor.rs | 215 ++++++++++----- crates/perry-runtime/src/pty/tests.rs | 125 +++++++++ crates/perry-runtime/src/pty/windows.rs | 258 ++++++++++++++++++ crates/perry-runtime/src/pty/windows_tests.rs | 228 ++++++++++++++++ crates/perry/tests/issue_8512_pty_process.rs | 77 ++++++ scripts/gc_runtime_root_holders.json | 4 +- 22 files changed, 1396 insertions(+), 416 deletions(-) create mode 100644 changelog.d/8512-native-pty-process.md create mode 100644 crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs create mode 100644 crates/perry-runtime/src/child_process/reactor/stdin.rs create mode 100644 crates/perry-runtime/src/pty/tests.rs create mode 100644 crates/perry-runtime/src/pty/windows.rs create mode 100644 crates/perry-runtime/src/pty/windows_tests.rs create mode 100644 crates/perry/tests/issue_8512_pty_process.rs diff --git a/changelog.d/8512-native-pty-process.md b/changelog.d/8512-native-pty-process.md new file mode 100644 index 0000000000..c7044ed7fa --- /dev/null +++ b/changelog.d/8512-native-pty-process.md @@ -0,0 +1,3 @@ +Add a native Windows ConPTY backend shared by `node-pty`, `@lydell/node-pty`, and `bun-pty`, including interactive input, resize, cwd/env, exit notification, and attached-process cleanup. PTY writes and Windows child-process stdin now drain on workers so backpressure cannot block cancellation or formatter timeouts. Implement PTY pause/resume, retain output before exit, and report Unix PTY spawn errors synchronously. + +Add native fixtures for PTY lifecycle, import aliases, LSP-style request/shutdown framing, and timeout/AbortSignal event ordering, plus a compiled TypeScript `bun-pty` round-trip. No N-API or JavaScript-runtime fallback is involved. Addresses #8512. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 7f6a182c25..98d0061f8a 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -185,6 +185,7 @@ pub const NATIVE_MODULES: &[&str] = &[ // the API-identical @lydell fork (opencode's static import) resolve to // the one perry-runtime implementation — no N-API addon involved. "node-pty", + "bun-pty", "@lydell/node-pty", // API-identical node-pty fork (see above) // #466: node-forge PKI subset (RSA keygen, X.509 build/sign, PEM). // Bundled wrapper at `crates/perry-ext-node-forge`; served natively @@ -292,6 +293,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[ "bun", // #6563: the pty lives in perry-runtime (child_process-style reactor). "node-pty", + "bun-pty", "@lydell/node-pty", ]; diff --git a/crates/perry-api-manifest/src/entries/part_3.rs b/crates/perry-api-manifest/src/entries/part_3.rs index 3f6adf23ce..e7df769587 100644 --- a/crates/perry-api-manifest/src/entries/part_3.rs +++ b/crates/perry-api-manifest/src/entries/part_3.rs @@ -1277,6 +1277,8 @@ pub(crate) const API_MANIFEST_PART_3: &[ApiEntry] = &[ // name passes the #463 surface gate. --- method("node-pty", "spawn", false, None), property("node-pty", "default"), + method("bun-pty", "spawn", false, None), + property("bun-pty", "default"), method("@lydell/node-pty", "spawn", false, None), property("@lydell/node-pty", "default"), // --- tty --- diff --git a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs index 4583fd834e..fe32b4b5b1 100644 --- a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs @@ -44,7 +44,7 @@ pub fn native_module_lookup( // #6563: @lydell/node-pty is an API-identical fork of node-pty // (opencode imports the fork, kimi-code the original); both route to // the one runtime pty implementation. - "@lydell/node-pty" => "node-pty", + "@lydell/node-pty" | "bun-pty" => "node-pty", m => m, }; // First pass: look for an exact class_filter match. diff --git a/crates/perry-codegen/src/nm_install.rs b/crates/perry-codegen/src/nm_install.rs index fab6d26474..8610269981 100644 --- a/crates/perry-codegen/src/nm_install.rs +++ b/crates/perry-codegen/src/nm_install.rs @@ -37,7 +37,7 @@ pub(crate) fn nm_install_symbol(name: &str) -> Option<&'static str> { "module" => Some("js_nm_install_module"), "net" => Some("js_nm_install_net"), // #6563: node-pty + the API-identical @lydell fork share one bucket. - "node-pty" | "@lydell/node-pty" => Some("js_nm_install_node_pty"), + "node-pty" | "@lydell/node-pty" | "bun-pty" => Some("js_nm_install_node_pty"), "os" => Some("js_nm_install_os"), "path" | "path/posix" | "path/win32" | "path.posix" | "path.win32" => { Some("js_nm_install_path") diff --git a/crates/perry-runtime/src/child_process/mod.rs b/crates/perry-runtime/src/child_process/mod.rs index 91e1c73c33..9be2e8fa65 100644 --- a/crates/perry-runtime/src/child_process/mod.rs +++ b/crates/perry-runtime/src/child_process/mod.rs @@ -9,7 +9,7 @@ pub mod fork; #[cfg(windows)] pub(crate) mod ipc_transport; #[cfg(windows)] -mod windows_fork; +pub(crate) mod windows_fork; // #2130: V8 structured-clone codec for `serialization: 'advanced'` IPC. pub(crate) mod v8_serde; // #2555: sync buffered `input`, `timeout`, and `maxBuffer` execution options. diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index 3d8f4f360b..fa1a6172e9 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -49,175 +49,9 @@ type CpReader = Box; type CpWriter = Box; type CpWaiter = Box (Option, Option) + Send>; -/// Node's default `writableHighWaterMark` for a child's stdin socket. -pub(super) const CP_STDIN_HIGH_WATER_MARK: usize = 64 * 1024; - -/// #9493: the writable side of a live child's stdin. -/// -/// `stdin.write()` used to `write_all` inline: it parked the main thread on a -/// full pipe until the child read (an LSP that stops reading hangs the -/// program), always returned `true`, never emitted `'drain'`, and committed -/// every byte before a `process.exit()` in the same tick. Node — libuv's -/// `uv_try_write` — commits what the pipe accepts right now, queues the -/// remainder for the loop, and judges the return value against the queued -/// length. This is that shape: the synchronous try-write is on the main -/// thread (bytes below pipe capacity land exactly as they did, including at -/// `process.exit()`), the remainder goes to a drain thread, and completion -/// (callbacks, `'drain'`, the deferred close for `end()`) is reported back -/// through the event queue like every other child event. -struct CpStdin { - /// Owns the pipe end; dropping it is the EOF the child sees. - writer: CpWriter, - /// Raw descriptor the drain thread dups. Unix only. - #[cfg(unix)] - fd: i32, - /// Bytes handed to the drain thread and not yet reported written — - /// `writableLength`. - queued: usize, - /// A `write()` returned `false` and no `'drain'` has fired since. - need_drain: bool, - /// `end()` ran while bytes were queued: close once they are written. - end_pending: bool, - /// Write/end callbacks that fire, in order, once the queue drains - /// (NaN-boxed closures; rooted by `cp_reactor_scan_roots_mut`). - callbacks: Vec, - /// Sender to the lazily-started drain thread. - #[cfg(unix)] - tx: Option>>, -} - -impl CpStdin { - #[cfg(unix)] - fn new(writer: CpWriter, fd: i32) -> Self { - // `O_NONBLOCK` is a property of this open file description alone — - // the child's read end is a separate description — so the try-write - // reports `WouldBlock` instead of parking the main thread. - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags >= 0 { - let _ = libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); - } - } - Self { - writer, - fd, - queued: 0, - need_drain: false, - end_pending: false, - callbacks: Vec::new(), - tx: None, - } - } - - #[cfg(not(unix))] - fn new(writer: CpWriter) -> Self { - Self { - writer, - queued: 0, - need_drain: false, - end_pending: false, - callbacks: Vec::new(), - } - } - - /// libuv's `uv__try_write`: write until the pipe would block. Returns how - /// many bytes were committed. A broken pipe counts the whole chunk as - /// consumed — `SIGPIPE` is ignored process-wide (#9402), the reader is - /// gone, and there is nobody left to deliver to. - fn try_write(&mut self, bytes: &[u8]) -> usize { - let mut offset = 0; - while offset < bytes.len() { - match self.writer.write(&bytes[offset..]) { - Ok(0) => break, - Ok(n) => offset += n, - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break, - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => return bytes.len(), - } - } - offset - } - - #[cfg(unix)] - fn enqueue(&mut self, handle: u64, bytes: Vec) { - if self.tx.is_none() { - let (tx, rx) = std::sync::mpsc::channel(); - cp_spawn_stdin_drain(handle, self.fd, rx); - self.tx = Some(tx); - } - if let Some(tx) = &self.tx { - let _ = tx.send(bytes); - } - } - - #[cfg(not(unix))] - fn enqueue(&mut self, handle: u64, bytes: Vec) { - // Blocking pipe handles: a short `write` only means an error, so the - // remainder is written inline as before and reported at once. - let _ = self.writer.write_all(&bytes); - cp_push_event(CpEvent::StdinWritten { - handle, - len: bytes.len(), - broken: false, - }); - } -} - -/// Drain thread for the bytes the pipe would not take synchronously. It -/// owns a `dup` of the descriptor, so the registry's writer can be dropped -/// (`end()`, child close, teardown) without pulling the fd out from under -/// an in-flight write; the dup closes when the channel ends, which is what -/// finally delivers EOF after an `end()` on a backed-up pipe. -#[cfg(unix)] -fn cp_spawn_stdin_drain(handle: u64, fd: i32, rx: std::sync::mpsc::Receiver>) { - let dup = unsafe { libc::dup(fd) }; - std::thread::spawn(move || { - let mut broken = dup < 0; - for chunk in rx { - let mut offset = 0; - while !broken && offset < chunk.len() { - let n = unsafe { - libc::write( - dup, - chunk[offset..].as_ptr() as *const libc::c_void, - chunk.len() - offset, - ) - }; - if n >= 0 { - offset += n as usize; - continue; - } - match std::io::Error::last_os_error().raw_os_error() { - Some(code) if code == libc::EAGAIN || code == libc::EWOULDBLOCK => { - let mut pfd = libc::pollfd { - fd: dup, - events: libc::POLLOUT, - revents: 0, - }; - unsafe { - libc::poll(&mut pfd, 1, -1); - } - } - Some(code) if code == libc::EINTR => {} - _ => broken = true, - } - } - cp_push_event(CpEvent::StdinWritten { - handle, - len: chunk.len(), - broken, - }); - if broken { - break; - } - } - if dup >= 0 { - unsafe { - libc::close(dup); - } - } - }); -} +mod stdin; +use stdin::CpStdin; +pub(super) use stdin::CP_STDIN_HIGH_WATER_MARK; /// Monotonic registry key for live children. static CP_NEXT_LIVE_ID: AtomicU64 = AtomicU64::new(1); @@ -1976,6 +1810,8 @@ pub(super) fn cp_live_stdin_queue_callback(handle: u64, callback_bits: u64) -> b mod integration; mod kill; +#[cfg(all(test, any(unix, windows)))] +mod lifecycle_tests; mod stdin_drain; mod timeout; #[cfg(all(test, windows))] diff --git a/crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs b/crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs new file mode 100644 index 0000000000..f6f343fc16 --- /dev/null +++ b/crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs @@ -0,0 +1,228 @@ +//! Native process fixtures for the OpenCode LSP/formatter lifecycle (#8512). +use super::*; +use std::cell::RefCell; +use std::io::BufRead; +use std::time::Instant; + +thread_local! { + static EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; + static OUTPUT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[test] +fn native_process_fixture() { + let Ok(mode) = std::env::var("PERRY_8512_CHILD_MODE") else { + return; + }; + if mode == "stalled" { + std::thread::sleep(Duration::from_secs(30)); + return; + } + let mut input = std::io::stdin().lock(); + let mut output = std::io::stdout().lock(); + for method in ["initialize", "shutdown"] { + let mut header = String::new(); + input.read_line(&mut header).unwrap(); + let len: usize = header + .trim() + .strip_prefix("Content-Length: ") + .unwrap() + .parse() + .unwrap(); + let mut blank = String::new(); + input.read_line(&mut blank).unwrap(); + assert_eq!(blank, "\r\n"); + let mut body = vec![0; len]; + input.read_exact(&mut body).unwrap(); + assert_eq!(body, method.as_bytes()); + let response = format!("{method}:ok"); + write!( + output, + "Content-Length: {}\r\n\r\n{response}", + response.len() + ) + .unwrap(); + output.flush().unwrap(); + } + let mut tail = Vec::new(); + input.read_to_end(&mut tail).unwrap(); + assert_eq!(tail, b"exit"); +} + +extern "C" fn event(closure: *const ClosureHeader, _a: f64, _b: f64) -> f64 { + let id = crate::closure::js_closure_get_capture_f64(closure, 0) as usize; + EVENTS.with(|e| { + e.borrow_mut() + .push(["spawn", "exit", "close", "error", "drain"][id].into()) + }); + cp_undefined() +} + +extern "C" fn data(_closure: *const ClosureHeader, value: f64) -> f64 { + OUTPUT.with(|o| o.borrow_mut().extend(cp_value_to_bytes(value))); + cp_undefined() +} + +fn register(target: f64, name: &str, id: usize) { + crate::closure::js_register_closure_arity(event as *const u8, 2); + let f = crate::closure::js_closure_alloc(event as *const u8, 1); + crate::closure::js_closure_set_capture_f64(f, 0, id as f64); + super::super::emitter::cp_register(target, cp_box_string(name), cp_box_ptr(f.cast())); +} + +fn spawn_fixture(mode: &str, timeout: Option) -> f64 { + EVENTS.with(|e| e.borrow_mut().clear()); + OUTPUT.with(|o| o.borrow_mut().clear()); + let scope = crate::gc::RuntimeHandleScope::new(); + let env = scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + for (key, value) in std::env::vars() { + cp_set_field(env.get_nanbox_f64(), key.as_bytes(), cp_box_string(&value)); + } + cp_set_field( + env.get_nanbox_f64(), + b"PERRY_8512_CHILD_MODE", + cp_box_string(mode), + ); + let opts = scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + cp_set_field(opts.get_nanbox_f64(), b"env", env.get_nanbox_f64()); + cp_set_field(opts.get_nanbox_f64(), b"windowsHide", TAG_TRUE_F64); + if let Some(timeout) = timeout { + cp_set_field(opts.get_nanbox_f64(), b"timeout", timeout); + } + let mut args = crate::array::js_array_alloc(3); + for arg in [ + "--exact", + "child_process::reactor::lifecycle_tests::native_process_fixture", + "--nocapture", + ] { + args = crate::array::js_array_push_f64(args, cp_box_string(arg)); + } + let path = std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(); + let ptr = crate::string::js_string_from_bytes(path.as_ptr(), path.len() as u32); + let cp = js_child_process_spawn_streams( + ptr as i64, + args as i64, + cp_object_ptr(opts.get_nanbox_f64()).unwrap() as i64, + ); + assert!(cp_get_field(cp, b"pid") > 0.0); + for (id, name) in ["spawn", "exit", "close", "error"].iter().enumerate() { + register(cp, name, id); + } + register(cp_get_field(cp, b"stdin"), "drain", 4); + crate::closure::js_register_closure_arity(data as *const u8, 1); + let f = crate::closure::js_closure_alloc(data as *const u8, 0); + super::super::emitter::cp_register( + cp_get_field(cp, b"stdout"), + cp_box_string("data"), + cp_box_ptr(f.cast()), + ); + cp +} + +fn pump_until(handle: u64, done: impl Fn() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !done() && Instant::now() < deadline { + cp_reactor_pump(); + std::thread::sleep(Duration::from_millis(5)); + } + if !done() { + cp_live_kill_signal(handle, 9); + } + assert!(done(), "process fixture timed out"); +} + +#[test] +fn lsp_bidirectional_frames_shutdown_and_close_order() { + let scope = crate::gc::RuntimeHandleScope::new(); + let cp = scope.root_nanbox_f64(spawn_fixture("lsp", None)); + let handle = cp_get_field(cp.get_nanbox_f64(), b"__cpHandle") as u64; + for method in ["initialize", "shutdown"] { + let bytes = format!("Content-Length: {}\r\n\r\n{method}", method.len()); + assert!(cp_live_stdin_write(handle, bytes.as_bytes(), None).is_some()); + pump_until(handle, || { + OUTPUT.with(|o| String::from_utf8_lossy(&o.borrow()).contains(&format!("{method}:ok"))) + }); + assert!( + cp_live_lock().as_ref().unwrap()[&handle].exited.is_none(), + "LSP must stay alive between requests" + ); + } + cp_live_stdin_write(handle, b"exit", None).unwrap(); + cp_live_stdin_close(handle); // EOF is deferred until the queued exit bytes drain. + pump_until(handle, || { + EVENTS.with(|e| e.borrow().iter().any(|v| v == "close")) + }); + assert_eq!( + EVENTS.with(|e| e.borrow().clone()), + ["spawn", "exit", "close"] + ); + assert_eq!(cp_get_field(cp.get_nanbox_f64(), b"exitCode"), 0.0); +} + +#[test] +fn formatter_timeout_runs_while_stdin_is_backpressured() { + let scope = crate::gc::RuntimeHandleScope::new(); + let cp = scope.root_nanbox_f64(spawn_fixture("stalled", Some(250.0))); + let handle = cp_get_field(cp.get_nanbox_f64(), b"__cpHandle") as u64; + let started = Instant::now(); + let outcome = cp_live_stdin_write(handle, &vec![b'x'; 1024 * 1024], None).unwrap(); + assert!(!outcome.below_high_water_mark); + assert!( + started.elapsed() < Duration::from_secs(2), + "stdin blocked the event loop" + ); + pump_until(handle, || { + EVENTS.with(|e| e.borrow().iter().any(|v| v == "close")) + }); + assert!(started.elapsed() < Duration::from_secs(5)); + let events = EVENTS.with(|e| e.borrow().clone()); + assert_eq!(events.first().map(String::as_str), Some("spawn")); + assert_eq!(events.last().map(String::as_str), Some("close")); + assert_eq!(events.iter().filter(|e| *e == "exit").count(), 1); + assert_eq!( + cp_get_field(cp.get_nanbox_f64(), b"killed").to_bits(), + TAG_TRUE_F64.to_bits() + ); + assert_eq!( + cp_value_to_string(cp_get_field(cp.get_nanbox_f64(), b"signalCode")).as_deref(), + Some("SIGTERM") + ); +} + +#[test] +fn abort_cancellation_orders_error_exit_close_and_removes_listener() { + let scope = crate::gc::RuntimeHandleScope::new(); + let cp = scope.root_nanbox_f64(spawn_fixture("stalled", None)); + let handle = cp_get_field(cp.get_nanbox_f64(), b"__cpHandle") as u64; + let controller = + scope.root_nanbox_f64(cp_box_ptr(crate::url::js_abort_controller_new().cast())); + let signal = scope.root_nanbox_f64(cp_box_ptr( + crate::url::js_abort_controller_signal(cp_object_ptr(controller.get_nanbox_f64()).unwrap()) + .cast(), + )); + cp_install_abort_signal(handle, Some(signal.get_nanbox_f64()), cp_undefined()); + assert_eq!( + crate::url::abort::js_abort_signal_listener_count( + cp_object_ptr(signal.get_nanbox_f64()).unwrap() + ), + 1.0 + ); + crate::url::js_abort_controller_abort(cp_object_ptr(controller.get_nanbox_f64()).unwrap()); + pump_until(handle, || { + EVENTS.with(|e| e.borrow().iter().any(|v| v == "close")) + }); + assert_eq!( + EVENTS.with(|e| e.borrow().clone()), + ["spawn", "error", "exit", "close"] + ); + assert_eq!( + crate::url::abort::js_abort_signal_listener_count( + cp_object_ptr(signal.get_nanbox_f64()).unwrap() + ), + 0.0 + ); + assert!(!cp_live_kill_signal(handle, 9)); +} diff --git a/crates/perry-runtime/src/child_process/reactor/stdin.rs b/crates/perry-runtime/src/child_process/reactor/stdin.rs new file mode 100644 index 0000000000..f92fcb2f2b --- /dev/null +++ b/crates/perry-runtime/src/child_process/reactor/stdin.rs @@ -0,0 +1,205 @@ +//! Nonblocking child stdin and asynchronous drain completion. + +use super::*; + +/// Node's default `writableHighWaterMark` for a child's stdin socket. +pub(crate) const CP_STDIN_HIGH_WATER_MARK: usize = 64 * 1024; + +/// #9493: the writable side of a live child's stdin. +/// +/// `stdin.write()` used to `write_all` inline: it parked the main thread on a +/// full pipe until the child read (an LSP that stops reading hangs the +/// program), always returned `true`, never emitted `'drain'`, and committed +/// every byte before a `process.exit()` in the same tick. Node — libuv's +/// `uv_try_write` — commits what the pipe accepts right now, queues the +/// remainder for the loop, and judges the return value against the queued +/// length. This is that shape: the synchronous try-write is on the main +/// thread (bytes below pipe capacity land exactly as they did, including at +/// `process.exit()`), the remainder goes to a drain thread, and completion +/// (callbacks, `'drain'`, the deferred close for `end()`) is reported back +/// through the event queue like every other child event. +pub(super) struct CpStdin { + /// Owns the pipe end; dropping it is the EOF the child sees. + writer: Option, + /// Raw descriptor the drain thread dups. Unix only. + #[cfg(unix)] + fd: i32, + /// Bytes handed to the drain thread and not yet reported written — + /// `writableLength`. + pub(super) queued: usize, + /// A `write()` returned `false` and no `'drain'` has fired since. + pub(super) need_drain: bool, + /// `end()` ran while bytes were queued: close once they are written. + pub(super) end_pending: bool, + /// Write/end callbacks that fire, in order, once the queue drains + /// (NaN-boxed closures; rooted by `cp_reactor_scan_roots_mut`). + pub(super) callbacks: Vec, + /// Sender to the lazily-started drain thread. + tx: Option>>, +} + +impl CpStdin { + #[cfg(unix)] + pub(super) fn new(writer: CpWriter, fd: i32) -> Self { + // `O_NONBLOCK` is a property of this open file description alone — + // the child's read end is a separate description — so the try-write + // reports `WouldBlock` instead of parking the main thread. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + let _ = libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + Self { + writer: Some(writer), + fd, + queued: 0, + need_drain: false, + end_pending: false, + callbacks: Vec::new(), + tx: None, + } + } + + #[cfg(not(unix))] + pub(super) fn new(writer: CpWriter) -> Self { + Self { + writer: Some(writer), + queued: 0, + need_drain: false, + end_pending: false, + callbacks: Vec::new(), + tx: None, + } + } + + /// libuv's `uv__try_write`: write until the pipe would block. Returns how + /// many bytes were committed. A broken pipe counts the whole chunk as + /// consumed — `SIGPIPE` is ignored process-wide (#9402), the reader is + /// gone, and there is nobody left to deliver to. + #[cfg(unix)] + pub(super) fn try_write(&mut self, bytes: &[u8]) -> usize { + let mut offset = 0; + while offset < bytes.len() { + match self.writer.as_mut().unwrap().write(&bytes[offset..]) { + Ok(0) => break, + Ok(n) => offset += n, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break, + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => return bytes.len(), + } + } + offset + } + + #[cfg(unix)] + pub(super) fn enqueue(&mut self, handle: u64, bytes: Vec) { + if self.tx.is_none() { + let (tx, rx) = std::sync::mpsc::channel(); + cp_spawn_stdin_drain(handle, self.fd, rx); + self.tx = Some(tx); + } + if let Some(tx) = &self.tx { + let _ = tx.send(bytes); + } + } + + // Windows anonymous pipes are synchronous. The worker owns the write + // handle; the main thread only queues bytes, so timers and abort handlers + // can run even when an LSP has stopped consuming its stdin. + #[cfg(not(unix))] + pub(super) fn try_write(&mut self, _bytes: &[u8]) -> usize { + 0 + } + + #[cfg(not(unix))] + pub(super) fn enqueue(&mut self, handle: u64, bytes: Vec) { + if self.tx.is_none() { + let (tx, rx) = std::sync::mpsc::channel::>(); + let mut writer = self + .writer + .take() + .expect("stdin worker owns the writer once"); + std::thread::spawn(move || { + for chunk in rx { + let broken = writer.write_all(&chunk).is_err(); + cp_push_event(CpEvent::StdinWritten { + handle, + len: chunk.len(), + broken, + }); + if broken { + break; + } + } + }); + self.tx = Some(tx); + } + if let Some(tx) = &self.tx { + let len = bytes.len(); + if tx.send(bytes).is_err() { + cp_push_event(CpEvent::StdinWritten { + handle, + len, + broken: true, + }); + } + } + } +} + +/// Drain thread for the bytes the pipe would not take synchronously. It +/// owns a `dup` of the descriptor, so the registry's writer can be dropped +/// (`end()`, child close, teardown) without pulling the fd out from under +/// an in-flight write; the dup closes when the channel ends, which is what +/// finally delivers EOF after an `end()` on a backed-up pipe. +#[cfg(unix)] +fn cp_spawn_stdin_drain(handle: u64, fd: i32, rx: std::sync::mpsc::Receiver>) { + let dup = unsafe { libc::dup(fd) }; + std::thread::spawn(move || { + let mut broken = dup < 0; + for chunk in rx { + let mut offset = 0; + while !broken && offset < chunk.len() { + let n = unsafe { + libc::write( + dup, + chunk[offset..].as_ptr() as *const libc::c_void, + chunk.len() - offset, + ) + }; + if n >= 0 { + offset += n as usize; + continue; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(code) if code == libc::EAGAIN || code == libc::EWOULDBLOCK => { + let mut pfd = libc::pollfd { + fd: dup, + events: libc::POLLOUT, + revents: 0, + }; + unsafe { + libc::poll(&mut pfd, 1, -1); + } + } + Some(code) if code == libc::EINTR => {} + _ => broken = true, + } + } + cp_push_event(CpEvent::StdinWritten { + handle, + len: chunk.len(), + broken, + }); + if broken { + break; + } + } + if dup >= 0 { + unsafe { + libc::close(dup); + } + } + }); +} diff --git a/crates/perry-runtime/src/child_process/windows_fork.rs b/crates/perry-runtime/src/child_process/windows_fork.rs index 6c5ced2e9e..2795034d67 100644 --- a/crates/perry-runtime/src/child_process/windows_fork.rs +++ b/crates/perry-runtime/src/child_process/windows_fork.rs @@ -449,7 +449,7 @@ impl Drop for AttributeList { } } -fn command_line(command: &Command) -> io::Result> { +pub(crate) fn command_line(command: &Command) -> io::Result> { let mut out = Vec::new(); append_quoted(&mut out, command.get_program())?; for arg in command.get_args() { @@ -497,7 +497,7 @@ fn append_quoted(out: &mut Vec, value: &OsStr) -> io::Result<()> { Ok(()) } -fn environment_block(command: &Command, clear: bool) -> io::Result> { +pub(crate) fn environment_block(command: &Command, clear: bool) -> io::Result> { let mut values: BTreeMap = BTreeMap::new(); if !clear { for (key, value) in std::env::vars_os() { @@ -535,7 +535,7 @@ fn environment_block(command: &Command, clear: bool) -> io::Result> { Ok(block) } -fn wide_nul(value: &OsStr) -> io::Result> { +pub(crate) fn wide_nul(value: &OsStr) -> io::Result> { let mut wide: Vec = value.encode_wide().collect(); if wide.contains(&0) { return Err(io::Error::new( diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 0f64471ee9..1e052a8f30 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1102,7 +1102,7 @@ pub fn gc_init() { reg_scanner!(crate::child_process::reactor::cp_reactor_scan_roots_mut); // #6563: live node-pty IPty objects are likewise reachable only from the // pty reactor's registry while their onData/onExit handlers are pending. - #[cfg(unix)] + #[cfg(any(unix, windows))] reg_scanner!(crate::pty::reactor::pty_reactor_scan_roots_mut); // #4911: a bound node:dgram socket is reachable only from the dgram // reactor's registry while its recv thread runs; scan + rewrite it so a GC diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 12ef1216b6..264f395c30 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -769,7 +769,7 @@ pub(crate) mod stdlib_pump { } // #6563: a live pty keeps the event loop alive (its onData/onExit // handlers are still pending), like a live spawn-reactor child. - #[cfg(unix)] + #[cfg(any(unix, windows))] if crate::pty::reactor::pty_reactor_has_live() { return 1; } diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 10c4746ab3..aae9100a82 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -641,7 +641,7 @@ pub(crate) fn normalize_native_module_alias(module_name: &str) -> &str { "path/win32" => "path.win32", // #6563: `@lydell/node-pty` is an API-identical fork of node-pty // (opencode's import); both names resolve to the one runtime pty. - "@lydell/node-pty" => "node-pty", + "@lydell/node-pty" | "bun-pty" => "node-pty", _ => module_name, } } diff --git a/crates/perry-runtime/src/object/native_module_registry.rs b/crates/perry-runtime/src/object/native_module_registry.rs index 7cb3ed658f..681e98cc17 100644 --- a/crates/perry-runtime/src/object/native_module_registry.rs +++ b/crates/perry-runtime/src/object/native_module_registry.rs @@ -104,7 +104,7 @@ fn nm_module_index(name: &str) -> Option { "module" => Some(NmBucket::Module), "net" => Some(NmBucket::Net), // #6563: node-pty + the API-identical @lydell fork, one bucket. - "node-pty" | "@lydell/node-pty" => Some(NmBucket::NodePty), + "node-pty" | "@lydell/node-pty" | "bun-pty" => Some(NmBucket::NodePty), "os" => Some(NmBucket::Os), "path" | "path.posix" | "path.win32" => Some(NmBucket::Path), "perf_histogram" | "perf_hooks" | "perf_observer" | "perf_observer_list" => { diff --git a/crates/perry-runtime/src/pty/mod.rs b/crates/perry-runtime/src/pty/mod.rs index b30ed0ce4d..5f120e0518 100644 --- a/crates/perry-runtime/src/pty/mod.rs +++ b/crates/perry-runtime/src/pty/mod.rs @@ -21,23 +21,30 @@ //! `onExit` fires `{ exitCode, signal }` — `signal` is the numeric signo for //! a signal death, `undefined` otherwise — matching node-pty's unix binding. //! -//! Windows/ConPTY is out of scope for this stage: on non-unix hosts -//! `js_pty_spawn` throws a descriptive `Error` (the same failure mode the -//! real node-pty has when its prebuilt addon is missing), so a consumer's -//! dynamic-import fallback path still engages. +//! Windows uses ConPTY with independent input/output workers. All three +//! import names (`node-pty`, `@lydell/node-pty`, `bun-pty`) share this facade. #[cfg(unix)] mod native; -#[cfg(unix)] +#[cfg(windows)] +#[path = "windows.rs"] +mod native; +#[cfg(any(unix, windows))] pub(crate) mod reactor; - -#[cfg(unix)] -pub use unix_impl::js_pty_spawn; +#[cfg(all(test, any(unix, windows)))] +mod tests; + +#[cfg(any(unix, windows))] +pub use platform_impl::js_pty_spawn; +#[cfg(any(unix, windows))] +pub(crate) use platform_impl::pty_emit; +#[cfg(any(unix, all(test, windows)))] +pub(crate) use platform_impl::pty_handle_of; #[cfg(unix)] -pub(crate) use unix_impl::{pty_emit, pty_handle_of, pty_register}; +pub(crate) use platform_impl::pty_register; -#[cfg(unix)] -mod unix_impl { +#[cfg(any(unix, windows))] +mod platform_impl { use super::{native, reactor}; use crate::child_process::{ cp_array_ptr, cp_box_ptr, cp_box_string, cp_build_object, cp_cast0, cp_cast1, cp_cast2, @@ -71,30 +78,33 @@ mod unix_impl { cp_set_field(target, &key, cp_box_ptr(arr as *const u8)); } - /// Invoke every listener registered on `target` for `event`. The listener - /// array is re-read each iteration so a moving GC during a handler call - /// can't strand us on a stale array pointer. + /// Emit a rooted snapshot so listener disposal and moving GC are safe. pub(crate) fn pty_emit(target: f64, event: &str, args: &[f64]) { + let scope = crate::gc::RuntimeHandleScope::new(); + let target = scope.root_nanbox_f64(target); + let args = scope.root_nanbox_f64_slice(args); + let prev = scope.root_nanbox_f64(crate::object::js_implicit_this_get()); let key = pty_listener_key(event); - let mut i: u32 = 0; - let this_scope = crate::gc::RuntimeHandleScope::new(); - // #9445: the displaced receiver is rooted ONCE here, not once per callback. - let prev = this_scope.root_nanbox_f64(crate::object::js_implicit_this_get()); - loop { - let arr = match cp_array_ptr(cp_get_field(target, &key)) { - Some(a) => a, - None => break, - }; - if i >= crate::array::js_array_length(arr) { - break; - } - let cb = crate::array::js_array_get_f64(arr, i); - js_implicit_this_set(target); + let Some(arr) = cp_array_ptr(cp_get_field(target.get_nanbox_f64(), &key)) else { + return; + }; + // Snapshot the listeners: disposing a subscription inside a callback + // must not skip the next callback or invalidate a moving GC reference. + let callbacks: Vec = (0..crate::array::js_array_length(arr)) + .map(|i| crate::array::js_array_get_f64(arr, i)) + .collect(); + let callbacks = scope.root_nanbox_f64_slice(&callbacks); + for cb in callbacks { + js_implicit_this_set(target.get_nanbox_f64()); + let current_args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&args); unsafe { - let _ = js_native_call_value(cb, args.as_ptr(), args.len()); + let _ = js_native_call_value( + cb.get_nanbox_f64(), + current_args.as_ptr(), + current_args.len(), + ); } js_implicit_this_set(prev.get_nanbox_f64()); - i += 1; } } @@ -206,8 +216,17 @@ mod unix_impl { cp_undefined() } - extern "C" fn pty_method_noop0(closure: *const ClosureHeader) -> f64 { - let _ = closure; + extern "C" fn pty_method_pause(closure: *const ClosureHeader) -> f64 { + if let Some(handle) = pty_handle_of(cp_this(closure)) { + reactor::pty_live_set_paused(handle, true); + } + cp_undefined() + } + + extern "C" fn pty_method_resume(closure: *const ClosureHeader) -> f64 { + if let Some(handle) = pty_handle_of(cp_this(closure)) { + reactor::pty_live_set_paused(handle, false); + } cp_undefined() } @@ -215,6 +234,7 @@ mod unix_impl { /// exposes the equivalent `destroy()`. Both are "hang up the terminal": /// kill with the default SIGHUP. extern "C" fn pty_method_dispose(closure: *const ClosureHeader) -> f64 { + pty_method_resume(closure); pty_method_kill(closure, cp_undefined()) } @@ -224,7 +244,7 @@ mod unix_impl { fn pty_parse_kill_signal(signal: f64) -> i32 { let bits = signal.to_bits(); if JSValue::from_bits(bits).is_undefined() || bits == 0 { - return libc::SIGHUP; + return 1; // SIGHUP; ConPTY terminates the session on Windows. } crate::child_process::cp_signal_from_value(signal) } @@ -307,7 +327,8 @@ mod unix_impl { js_register_closure_arity(pty_method_write as *const u8, 1); js_register_closure_arity(pty_method_resize as *const u8, 2); js_register_closure_arity(pty_method_kill as *const u8, 1); - js_register_closure_arity(pty_method_noop0 as *const u8, 0); + js_register_closure_arity(pty_method_pause as *const u8, 0); + js_register_closure_arity(pty_method_resume as *const u8, 0); js_register_closure_arity(pty_method_dispose as *const u8, 0); js_register_closure_arity(pty_disposable_dispose as *const u8, 0); } @@ -321,8 +342,8 @@ mod unix_impl { ("write", cp_cast1(pty_method_write)), ("resize", cp_cast2(pty_method_resize)), ("kill", cp_cast1(pty_method_kill)), - ("pause", cp_cast0(pty_method_noop0)), - ("resume", cp_cast0(pty_method_noop0)), + ("pause", cp_cast0(pty_method_pause)), + ("resume", cp_cast0(pty_method_resume)), ("dispose", cp_cast0(pty_method_dispose)), ]; let obj = cp_build_object(&methods, PTY_SHAPE_ID + methods.len() as u32); @@ -382,7 +403,7 @@ mod unix_impl { cp_set_field(ipty, b"rows", rows as f64); // node-pty's `process` is the terminal's foreground process name; // the spawned file's basename is the faithful static answer. - let proc_name = file.rsplit('/').next().unwrap_or(&file); + let proc_name = file.rsplit(['/', '\\']).next().unwrap_or(&file); cp_set_field(ipty, b"process", cp_box_string(proc_name)); cp_set_field( ipty, @@ -396,7 +417,7 @@ mod unix_impl { ipty } - #[cfg(test)] + #[cfg(all(test, unix))] pub(super) mod tests { use super::*; use crate::string::js_string_from_bytes; @@ -528,14 +549,12 @@ mod unix_impl { } } -/// Windows/ConPTY stub (#6563 stage 2): throw a descriptive error so -/// consumers' import-failure fallbacks (kimi's non-pty terminal backend) -/// engage instead of crashing. -#[cfg(not(unix))] +/// PTYs require Unix or Windows ConPTY. +#[cfg(not(any(unix, windows)))] #[no_mangle] pub extern "C" fn js_pty_spawn(_file_bits: i64, _args_bits: i64, _opts_bits: i64) -> f64 { crate::exception::js_throw(crate::child_process::cp_make_error( - "node-pty: this platform is not supported yet by the perry runtime (POSIX only; ConPTY tracked in #6563)", + "node-pty: this platform does not support native PTYs", &[], )); } diff --git a/crates/perry-runtime/src/pty/native.rs b/crates/perry-runtime/src/pty/native.rs index 5737dabb0d..539ca44d5a 100644 --- a/crates/perry-runtime/src/pty/native.rs +++ b/crates/perry-runtime/src/pty/native.rs @@ -7,12 +7,9 @@ //! Model: //! * [`open_pty_pair`] — `openpty(3)` with node-pty's sane default termios //! (echo on, canonical mode, ISIG, 38400 baud) and the requested winsize. -//! * [`spawn_in_pty`] — fork; the child becomes a session leader, takes the -//! slave as its controlling terminal (`TIOCSCTTY`), dups it onto -//! stdin/stdout/stderr and execs. Everything the child touches (argv, envp, -//! cwd, resolved exec candidates) is pre-marshalled in the parent because -//! only async-signal-safe calls are allowed between `fork` and `execve` in -//! a multithreaded process. +//! * [`spawn_in_pty`] uses std process creation with a pre-exec hook that takes +//! the slave as the controlling terminal. Rust's exec error pipe reports +//! invalid programs, arguments, and working directories to the parent. //! * [`wait_child`] — blocking `waitpid` reap (run on a dedicated thread by //! the reactor), decoded to node's `(exitCode, signal)` split. //! * [`resize_pty`] / [`signal_pid`] — `TIOCSWINSZ` and `kill(2)`. @@ -22,7 +19,6 @@ #![allow(clippy::manual_c_str_literals)] -use std::ffi::CString; use std::io; use std::os::unix::io::RawFd; @@ -116,118 +112,49 @@ pub(crate) fn open_pty_pair(cols: u16, rows: u16) -> io::Result<(RawFd, RawFd)> } unsafe { libc::fcntl(master, libc::F_SETFD, libc::FD_CLOEXEC); + libc::fcntl(slave, libc::F_SETFD, libc::FD_CLOEXEC); } Ok((master, slave)) } -/// Resolve `file` to the execve candidate list — an absolute/relative path is -/// taken as-is; a bare name is expanded against the child env's `PATH` (the -/// same order `execvp` would try). Resolution happens in the PARENT so the -/// post-fork child only calls the async-signal-safe `execve`. -fn resolve_exec_candidates(file: &str, env: &[(String, String)]) -> Vec { - if file.contains('/') { - return CString::new(file).ok().into_iter().collect(); - } - let path = env - .iter() - .find(|(k, _)| k == "PATH") - .map(|(_, v)| v.clone()) - .or_else(|| std::env::var("PATH").ok()) - .unwrap_or_else(|| "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string()); - path.split(':') - .filter(|d| !d.is_empty()) - .filter_map(|d| CString::new(format!("{d}/{file}")).ok()) - .collect() -} - -/// Fork + exec `req.file` with the slave side of a fresh pty as its -/// controlling terminal and stdio. Returns the child pid + master fd. -/// -/// The child half runs only async-signal-safe calls (`setsid`, `ioctl`, -/// `dup2`, `chdir`, `execve`, `_exit`); all heap work happens before `fork`. +/// Spawn with the slave as stdio and the controlling terminal. Rust's exec +/// error pipe reports cwd/exec failures synchronously and reaps failed children. +/// The pre_exec hook performs only async-signal-safe OS calls. pub(crate) fn spawn_in_pty(req: &PtySpawnRequest) -> io::Result { - let candidates = resolve_exec_candidates(&req.file, &req.env); - if candidates.is_empty() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("spawn {} ENOENT", req.file), - )); - } - - // argv = [file, ...args]; entries with interior NULs are dropped rather - // than failing the whole spawn (they could never be exec'd anyway). - let argv_c: Vec = std::iter::once(req.file.as_str()) - .chain(req.args.iter().map(|s| s.as_str())) - .filter_map(|s| CString::new(s).ok()) - .collect(); - let mut argv_ptrs: Vec<*const libc::c_char> = argv_c.iter().map(|c| c.as_ptr()).collect(); - argv_ptrs.push(std::ptr::null()); - - let envp_c: Vec = req - .env - .iter() - .filter_map(|(k, v)| CString::new(format!("{k}={v}")).ok()) - .collect(); - let mut envp_ptrs: Vec<*const libc::c_char> = envp_c.iter().map(|c| c.as_ptr()).collect(); - envp_ptrs.push(std::ptr::null()); - - let cwd_c = match &req.cwd { - Some(d) => match CString::new(d.as_str()) { - Ok(c) => Some(c), - Err(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "cwd contains a NUL byte", - )) - } - }, - None => None, - }; + use std::os::unix::io::{FromRawFd, IntoRawFd}; + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; let (master, slave) = open_pty_pair(req.cols, req.rows)?; - - match unsafe { libc::fork() } { - -1 => { - let err = io::Error::last_os_error(); - unsafe { - libc::close(master); - libc::close(slave); - } - Err(err) - } - 0 => { - // Child. Async-signal-safe calls ONLY from here to execve. - unsafe { - libc::setsid(); - // Infer libc's platform-specific `Ioctl` type. Android x86_64 - // uses `c_int` while BSD/macOS uses `c_ulong`; forcing either - // concrete type makes the other platform fail to compile. - libc::ioctl(slave, libc::TIOCSCTTY as _, 0); - libc::dup2(slave, 0); - libc::dup2(slave, 1); - libc::dup2(slave, 2); - if slave > 2 { - libc::close(slave); - } - libc::close(master); - if let Some(cwd) = &cwd_c { - if libc::chdir(cwd.as_ptr()) != 0 { - libc::_exit(127); - } - } - for p in &candidates { - libc::execve(p.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr()); - } - libc::_exit(127); - } - } - pid => { - unsafe { - libc::close(slave); + let master = unsafe { std::fs::File::from_raw_fd(master) }; + let slave = unsafe { std::fs::File::from_raw_fd(slave) }; + let mut command = Command::new(&req.file); + command + .args(&req.args) + .env_clear() + .envs(req.env.iter().cloned()); + if let Some(cwd) = &req.cwd { + command.current_dir(cwd); + } + command.stdin(Stdio::from(slave.try_clone()?)); + command.stdout(Stdio::from(slave.try_clone()?)); + command.stderr(Stdio::from(slave)); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 || libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { + return Err(io::Error::last_os_error()); } - Ok(PtyChild { pid, master }) - } + Ok(()) + }); } + let child = command.spawn()?; + let pid = child.id() as i32; + // Child::drop does not reap: the reactor's waiter owns waitpid. + drop(child); + Ok(PtyChild { + pid, + master: master.into_raw_fd(), + }) } /// Blocking reap of `pid`. Returns node-pty's `(exitCode, signal)` split: @@ -411,20 +338,9 @@ mod tests { cols: 80, rows: 24, }); - // Bare name + dead PATH: candidates exist but every execve fails → - // the child _exit(127)s. An empty candidate list errors in the - // parent. Both shapes are acceptable; this test pins the parent-side - // error for the no-candidate case. - match err { - Ok(child) => { - let (code, signal) = wait_child(child.pid); - assert_eq!(code, Some(127), "exec failure must exit 127"); - assert_eq!(signal, None); - unsafe { - libc::close(child.master); - } - } - Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound), - } + let error = err + .err() + .expect("exec failure must reach the spawning thread"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); } } diff --git a/crates/perry-runtime/src/pty/reactor.rs b/crates/perry-runtime/src/pty/reactor.rs index ec99871d39..1232a3a388 100644 --- a/crates/perry-runtime/src/pty/reactor.rs +++ b/crates/perry-runtime/src/pty/reactor.rs @@ -20,7 +20,10 @@ //! [`pty_reactor_has_live`] and are GC roots via [`pty_reactor_scan_roots_mut`]. use std::collections::HashMap; +#[cfg(unix)] use std::os::unix::io::RawFd; +#[cfg(windows)] +type RawFd = std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, PoisonError}; @@ -58,8 +61,11 @@ static PTY_EVENT_QUEUE: Mutex> = Mutex::new(Vec::new()); struct LivePty { /// NaN-boxed IPty object — a GC root (see `pty_reactor_scan_roots_mut`). ipty_bits: u64, + #[cfg(unix)] pid: i32, master: RawFd, + #[cfg(unix)] + write_tx: std::sync::mpsc::Sender>, /// Bytes of an incomplete trailing UTF-8 sequence from the previous /// chunk, prepended to the next one so multi-byte characters split /// across `read` boundaries decode intact. @@ -72,6 +78,8 @@ struct LivePty { closed: bool, /// Whether this PTY currently contributes an active event-loop handle. refed: bool, + paused: bool, + pending: Vec>, } static PTY_LIVE: Mutex>> = Mutex::new(None); @@ -103,6 +111,7 @@ fn pty_push_event(ev: PtyEvent) { /// registry entry (closed by the pump after EOF+exit), so the raw `read` here /// never races a close: the pump only closes once `Eof` has been consumed, /// i.e. after this thread has already returned. +#[cfg(unix)] fn pty_spawn_reader(handle: u64, master: RawFd) { std::thread::spawn(move || { let mut buf = [0u8; 8192]; @@ -127,6 +136,7 @@ fn pty_spawn_reader(handle: u64, master: RawFd) { } /// Spawn the waiter thread that reaps `pid` and reports its exit status. +#[cfg(unix)] fn pty_spawn_waiter(handle: u64, pid: i32) { std::thread::spawn(move || { let (code, signal) = native::wait_child(pid); @@ -138,6 +148,37 @@ fn pty_spawn_waiter(handle: u64, pid: i32) { }); } +#[cfg(windows)] +fn pty_spawn_reader(handle: u64, master: RawFd) { + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match native::read_pty(&master, &mut buf) { + Ok(0) => break, + Ok(n) => pty_push_event(PtyEvent::Data { + handle, + bytes: buf[..n].to_vec(), + }), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + pty_push_event(PtyEvent::Eof { handle }); + }); +} + +#[cfg(windows)] +fn pty_spawn_waiter(handle: u64, master: RawFd) { + std::thread::spawn(move || { + let (code, signal) = native::wait_child(master); + pty_push_event(PtyEvent::Exited { + handle, + code, + signal, + }); + }); +} + /// Register a freshly-spawned pty child: insert the registry entry, start the /// reader + waiter threads and wake the loop. Returns the registry handle. pub(super) fn pty_register_live(ipty: f64, child: native::PtyChild) -> u64 { @@ -149,55 +190,69 @@ pub(super) fn pty_register_live(ipty: f64, child: native::PtyChild) -> u64 { handle, LivePty { ipty_bits: ipty.to_bits(), + #[cfg(unix)] pid: child.pid, - master: child.master, + master: child.master.clone(), + #[cfg(unix)] + write_tx: pty_spawn_writer(child.master), utf8_carry: Vec::new(), eof: false, exited: None, closed: false, refed: true, + paused: false, + pending: Vec::new(), }, ); } crate::stdlib_pump::register_runtime_pump(1, pty_reactor_pump_extern); PTY_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); PTY_REFED_COUNT.fetch_add(1, Ordering::SeqCst); - pty_spawn_reader(handle, child.master); + pty_spawn_reader(handle, child.master.clone()); + #[cfg(unix)] pty_spawn_waiter(handle, child.pid); + #[cfg(windows)] + pty_spawn_waiter(handle, child.master); crate::event_pump::js_notify_main_thread(); handle } -/// Write `bytes` to a live pty's master. Returns whether the write succeeded. +/// Queue PTY input without blocking the event loop on terminal backpressure. pub(crate) fn pty_live_write(handle: u64, bytes: &[u8]) -> bool { - let master = { - let guard = pty_live_lock(); - match guard.as_ref().and_then(|m| m.get(&handle)) { - Some(lp) if !lp.closed => lp.master, - _ => return false, - } + let guard = pty_live_lock(); + let Some(lp) = guard.as_ref().and_then(|m| m.get(&handle)) else { + return false; }; - // Plain blocking write outside the lock (a full pty output buffer must - // not wedge the registry). Shells drain fast; matching node-pty's - // synchronous unix write path. - let mut off = 0; - while off < bytes.len() { - let n = unsafe { - libc::write( - master, - bytes[off..].as_ptr() as *const libc::c_void, - bytes.len() - off, - ) - }; - if n < 0 { - if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { - continue; + if lp.closed || lp.exited.is_some() { + return false; + } + #[cfg(unix)] + { + lp.write_tx.send(bytes.to_vec()).is_ok() + } + #[cfg(windows)] + { + native::write_pty(&lp.master, bytes) + } +} + +#[cfg(unix)] +fn pty_spawn_writer(master: RawFd) -> std::sync::mpsc::Sender> { + use std::io::Write; + use std::os::unix::io::FromRawFd; + let (tx, rx) = std::sync::mpsc::channel::>(); + let fd = unsafe { libc::fcntl(master, libc::F_DUPFD_CLOEXEC, 0) }; + if fd >= 0 { + let mut writer = unsafe { std::fs::File::from_raw_fd(fd) }; + std::thread::spawn(move || { + for bytes in rx { + if writer.write_all(&bytes).is_err() { + break; + } } - return false; - } - off += n as usize; + }); } - true + tx } /// `TIOCSWINSZ` a live pty. Returns whether the ioctl succeeded. @@ -205,7 +260,7 @@ pub(crate) fn pty_live_resize(handle: u64, cols: u16, rows: u16) -> bool { let master = { let guard = pty_live_lock(); match guard.as_ref().and_then(|m| m.get(&handle)) { - Some(lp) if !lp.closed => lp.master, + Some(lp) if !lp.closed => lp.master.clone(), _ => return false, } }; @@ -213,11 +268,12 @@ pub(crate) fn pty_live_resize(handle: u64, cols: u16, rows: u16) -> bool { } /// Toggle raw mode on a live PTY. +#[cfg(unix)] pub(crate) fn pty_live_set_raw_mode(handle: u64, enabled: bool) -> bool { let master = { let guard = pty_live_lock(); match guard.as_ref().and_then(|m| m.get(&handle)) { - Some(lp) if !lp.closed => lp.master, + Some(lp) if !lp.closed => lp.master.clone(), _ => return false, } }; @@ -225,6 +281,7 @@ pub(crate) fn pty_live_set_raw_mode(handle: u64, enabled: bool) -> bool { } /// Signal a live pty child. Skipped once reaped (the pid may be recycled). +#[cfg(unix)] pub(crate) fn pty_live_kill(handle: u64, signo: i32) -> bool { let pid = { let guard = pty_live_lock(); @@ -236,7 +293,24 @@ pub(crate) fn pty_live_kill(handle: u64, signo: i32) -> bool { native::signal_pid(pid, signo) } +#[cfg(windows)] +pub(crate) fn pty_live_kill(handle: u64, signo: i32) -> bool { + let guard = pty_live_lock(); + match guard.as_ref().and_then(|m| m.get(&handle)) { + Some(lp) if lp.exited.is_none() => native::signal_pty(&lp.master, signo), + _ => false, + } +} + +pub(super) fn pty_live_set_paused(handle: u64, paused: bool) { + if let Some(lp) = pty_live_lock().as_mut().and_then(|m| m.get_mut(&handle)) { + lp.paused = paused; + } + crate::event_pump::js_notify_main_thread(); +} + /// Toggle one PTY's event-loop keepalive bit. Calls are idempotent. +#[cfg(unix)] pub(crate) fn pty_live_set_refed(handle: u64, refed: bool) -> bool { { let mut guard = pty_live_lock(); @@ -323,13 +397,33 @@ fn pty_reactor_pump_inner() { // --- Phase A: drain queued data/eof/exited events. Snapshot state under // a brief lock, emit OUTSIDE it (handlers allocate / can trigger GC, and // the GC root scanner takes the same lock on this thread). --- - let events = std::mem::take(&mut *pty_queue_lock()); + let mut events = Vec::new(); + if let Some(map) = pty_live_lock().as_mut() { + for (handle, lp) in map { + if !lp.paused { + events.extend(std::mem::take(&mut lp.pending).into_iter().map(|bytes| { + PtyEvent::Data { + handle: *handle, + bytes, + } + })); + } + } + } + events.extend(std::mem::take(&mut *pty_queue_lock())); for ev in events { match ev { PtyEvent::Data { handle, bytes } => { let decoded = { let mut guard = pty_live_lock(); match guard.as_mut().and_then(|m| m.get_mut(&handle)) { + // A callback for another PTY may resume this one + // after the pending-data snapshot above. Keep newer + // chunks behind its older, still-pending output. + Some(lp) if lp.paused || !lp.pending.is_empty() => { + lp.pending.push(bytes); + None + } Some(lp) => { let text = pty_decode_utf8(&mut lp.utf8_carry, &bytes); Some((lp.ipty_bits, text)) @@ -339,39 +433,16 @@ fn pty_reactor_pump_inner() { }; if let Some((ipty_bits, text)) = decoded { if !text.is_empty() { - let ipty = f64::from_bits(ipty_bits); - super::pty_emit( - ipty, - "data", - &[crate::child_process::cp_box_string(&text)], - ); + let scope = crate::gc::RuntimeHandleScope::new(); + let ipty = scope.root_nanbox_f64(f64::from_bits(ipty_bits)); + let chunk = crate::child_process::cp_box_string(&text); + super::pty_emit(ipty.get_nanbox_f64(), "data", &[chunk]); } } } PtyEvent::Eof { handle } => { - let flush = { - let mut guard = pty_live_lock(); - match guard.as_mut().and_then(|m| m.get_mut(&handle)) { - Some(lp) => { - lp.eof = true; - // Whatever is still in the carry can never - // complete — flush it lossily. - let tail = std::mem::take(&mut lp.utf8_carry); - Some((lp.ipty_bits, tail)) - } - None => None, - } - }; - if let Some((ipty_bits, tail)) = flush { - if !tail.is_empty() { - let text = String::from_utf8_lossy(&tail).into_owned(); - let ipty = f64::from_bits(ipty_bits); - super::pty_emit( - ipty, - "data", - &[crate::child_process::cp_box_string(&text)], - ); - } + if let Some(lp) = pty_live_lock().as_mut().and_then(|m| m.get_mut(&handle)) { + lp.eof = true; } } PtyEvent::Exited { @@ -392,8 +463,9 @@ fn pty_reactor_pump_inner() { // EOF, so every `data` chunk has already been delivered. --- struct PtyCloseItem { handle: u64, - ipty_bits: u64, + #[cfg(unix)] master: RawFd, + tail: Vec, code: Option, signal: Option, refed: bool, @@ -407,12 +479,13 @@ fn pty_reactor_pump_inner() { continue; } if let Some((code, signal)) = lp.exited { - if lp.eof { + if lp.eof && !lp.paused && lp.pending.is_empty() { lp.closed = true; out.push(PtyCloseItem { handle: *h, - ipty_bits: lp.ipty_bits, - master: lp.master, + #[cfg(unix)] + master: lp.master.clone(), + tail: std::mem::take(&mut lp.utf8_carry), code, signal, refed: lp.refed, @@ -424,10 +497,17 @@ fn pty_reactor_pump_inner() { out }; for item in to_close { + #[cfg(unix)] unsafe { libc::close(item.master); } - let ipty = f64::from_bits(item.ipty_bits); + let scope = crate::gc::RuntimeHandleScope::new(); + let bits = pty_live_lock().as_ref().unwrap()[&item.handle].ipty_bits; + let ipty = scope.root_nanbox_f64(f64::from_bits(bits)); + if !item.tail.is_empty() { + let chunk = crate::child_process::cp_box_string(&String::from_utf8_lossy(&item.tail)); + super::pty_emit(ipty.get_nanbox_f64(), "data", &[chunk]); + } // node-pty's exit payload: `{ exitCode: number, signal?: number }` — // signal is the numeric signo for a signal death, undefined otherwise. let exit_code = item.code.unwrap_or(0) as f64; @@ -440,10 +520,11 @@ fn pty_reactor_pump_inner() { "exitCode", exit_code, "signal", signal_val, ) as i64) }; + let payload = scope.root_nanbox_f64(payload); // Mirror the terminal state onto the IPty object before emitting so // a handler reading `pty.process` state observes post-exit values. - cp_set_field(ipty, b"exitCode", exit_code); - super::pty_emit(ipty, "exit", &[payload]); + cp_set_field(ipty.get_nanbox_f64(), b"exitCode", exit_code); + super::pty_emit(ipty.get_nanbox_f64(), "exit", &[payload.get_nanbox_f64()]); if let Some(map) = pty_live_lock().as_mut() { map.remove(&item.handle); } diff --git a/crates/perry-runtime/src/pty/tests.rs b/crates/perry-runtime/src/pty/tests.rs new file mode 100644 index 0000000000..1ec1a65f98 --- /dev/null +++ b/crates/perry-runtime/src/pty/tests.rs @@ -0,0 +1,125 @@ +//! Shared JS facade acceptance: all import aliases, subscriptions, pause/resume, +//! output before exit, and registry cleanup on both PTY backends. +use super::{pty_handle_of, reactor}; +use crate::child_process::*; +use crate::closure::ClosureHeader; +use std::cell::{Cell, RefCell}; +use std::time::{Duration, Instant}; + +thread_local! { + static OUTPUT: RefCell = const { RefCell::new(String::new()) }; + static EXITS: RefCell> = const { RefCell::new(Vec::new()) }; + static REMOVED_CALLS: Cell = const { Cell::new(0) }; +} + +extern "C" fn removed(_closure: *const ClosureHeader, _chunk: f64) -> f64 { + REMOVED_CALLS.with(|n| n.set(n.get() + 1)); + cp_undefined() +} + +extern "C" fn data(_closure: *const ClosureHeader, chunk: f64) -> f64 { + OUTPUT.with(|o| o.borrow_mut().push_str(&cp_value_to_string(chunk).unwrap())); + cp_undefined() +} + +extern "C" fn exited(_closure: *const ClosureHeader, event: f64) -> f64 { + EXITS.with(|e| e.borrow_mut().push(cp_get_field(event, b"exitCode"))); + cp_undefined() +} + +fn callback(f: extern "C" fn(*const ClosureHeader, f64) -> f64) -> f64 { + crate::closure::js_register_closure_arity(f as *const u8, 1); + cp_box_ptr(crate::closure::js_closure_alloc(f as *const u8, 0).cast()) +} + +fn call(target: f64, name: &[u8], args: &[f64]) -> f64 { + unsafe { + crate::closure::js_native_call_value(cp_get_field(target, name), args.as_ptr(), args.len()) + } +} + +#[test] +fn aliases_share_native_spawn_and_paused_output_precedes_exit() { + extern "C" { + fn js_nm_install_node_pty(); + } + unsafe { + js_nm_install_node_pty(); + } + for alias in ["node-pty", "@lydell/node-pty", "bun-pty"] { + OUTPUT.with(|o| o.borrow_mut().clear()); + EXITS.with(|e| e.borrow_mut().clear()); + REMOVED_CALLS.with(|n| n.set(0)); + let scope = crate::gc::RuntimeHandleScope::new(); + let ns = scope.root_nanbox_f64(crate::object::js_create_native_module_namespace( + alias.as_ptr(), + alias.len(), + )); + let spawn = scope.root_nanbox_f64(cp_get_field(ns.get_nanbox_f64(), b"spawn")); + #[cfg(windows)] + let (shell, args, script) = ( + "cmd.exe", + vec!["/d", "/q"], + "echo %PERRY_PTY_MARKER%\r\nexit\r\n", + ); + #[cfg(unix)] + let (shell, args, script) = ("sh", vec![], "printf '%s\\n' \"$PERRY_PTY_MARKER\"\nexit\n"); + let mut argv = crate::array::js_array_alloc(args.len() as u32); + for arg in args { + argv = crate::array::js_array_push_f64(argv, cp_box_string(arg)); + } + let argv = scope.root_nanbox_f64(cp_box_ptr(argv.cast())); + let env = scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + for (key, value) in std::env::vars() { + cp_set_field(env.get_nanbox_f64(), key.as_bytes(), cp_box_string(&value)); + } + cp_set_field( + env.get_nanbox_f64(), + b"PERRY_PTY_MARKER", + cp_box_string("native_alias_8512"), + ); + let opts = scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + cp_set_field(opts.get_nanbox_f64(), b"env", env.get_nanbox_f64()); + let args = [ + cp_box_string(shell), + argv.get_nanbox_f64(), + opts.get_nanbox_f64(), + ]; + let term = scope.root_nanbox_f64(unsafe { + crate::closure::js_native_call_value(spawn.get_nanbox_f64(), args.as_ptr(), args.len()) + }); + assert!(cp_get_field(term.get_nanbox_f64(), b"pid") > 0.0); + let handle = pty_handle_of(term.get_nanbox_f64()).unwrap(); + let disposable = call(term.get_nanbox_f64(), b"onData", &[callback(removed)]); + call(disposable, b"dispose", &[]); + call(term.get_nanbox_f64(), b"onData", &[callback(data)]); + call(term.get_nanbox_f64(), b"onExit", &[callback(exited)]); + call(term.get_nanbox_f64(), b"pause", &[]); + call(term.get_nanbox_f64(), b"write", &[cp_box_string(script)]); + let paused = Instant::now() + Duration::from_millis(150); + while Instant::now() < paused { + reactor::pty_reactor_pump(); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(OUTPUT.with(|o| o.borrow().is_empty())); + assert!(EXITS.with(|e| e.borrow().is_empty())); + call(term.get_nanbox_f64(), b"resume", &[]); + let deadline = Instant::now() + Duration::from_secs(15); + while reactor::pty_live_count_for_test() != 0 && Instant::now() < deadline { + reactor::pty_reactor_pump(); + std::thread::sleep(Duration::from_millis(5)); + } + if reactor::pty_live_count_for_test() != 0 { + reactor::pty_live_kill(handle, 9); + } + assert_eq!(reactor::pty_live_count_for_test(), 0, "{alias}: leaked PTY"); + assert_eq!(EXITS.with(|e| e.borrow().clone()), vec![0.0], "{alias}"); + let output = OUTPUT.with(|o| o.borrow().clone()); + assert!(output.contains("native_alias_8512"), "{alias}: {output:?}"); + assert_eq!( + REMOVED_CALLS.with(Cell::get), + 0, + "{alias}: disposed listener fired" + ); + } +} diff --git a/crates/perry-runtime/src/pty/windows.rs b/crates/perry-runtime/src/pty/windows.rs new file mode 100644 index 0000000000..7530ef2131 --- /dev/null +++ b/crates/perry-runtime/src/pty/windows.rs @@ -0,0 +1,258 @@ +//! ConPTY OS layer. Only owned OS handles and bytes cross worker threads. +//! The waiter closes the console while the reader continues draining its +//! final frame; ClosePseudoConsole must never run on the JavaScript thread. + +use std::fs::File; +use std::io::{self, Read, Write}; +use std::mem::{size_of, zeroed}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::process::Command; +use std::sync::{mpsc, Arc, Mutex}; + +use windows_sys::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::System::Console::{ + ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON, +}; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess, + InitializeProcThreadAttributeList, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE, + LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + STARTF_USESTDHANDLES, STARTUPINFOEXW, +}; + +use crate::child_process::windows_fork::{command_line, environment_block, wide_nul}; + +pub(crate) struct PtySpawnRequest { + pub file: String, + pub args: Vec, + pub env: Vec<(String, String)>, + pub cwd: Option, + pub cols: u16, + pub rows: u16, +} + +pub(crate) struct PtyChild { + pub pid: i32, + pub master: Arc, +} + +pub(crate) struct PtySession { + console: Mutex, + process: OwnedHandle, + output: File, + input: Mutex>>>, +} + +struct Console(HPCON); + +impl Drop for Console { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { ClosePseudoConsole(self.0) }; + } + } +} + +fn pipe() -> io::Result<(File, File)> { + let mut read = std::ptr::null_mut(); + let mut write = std::ptr::null_mut(); + if unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) } == 0 { + return Err(io::Error::last_os_error()); + } + // CreatePipe returned two distinct, non-inheritable handles. + Ok(unsafe { (File::from_raw_handle(read), File::from_raw_handle(write)) }) +} + +struct Attributes { + storage: Vec, + initialized: bool, +} + +impl Attributes { + fn console(console: HPCON) -> io::Result { + let mut bytes = 0; + unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes) }; + if bytes == 0 { + return Err(io::Error::last_os_error()); + } + let mut list = Self { + storage: vec![0; bytes.div_ceil(size_of::())], + initialized: false, + }; + if unsafe { InitializeProcThreadAttributeList(list.ptr(), 1, 0, &mut bytes) } == 0 { + return Err(io::Error::last_os_error()); + } + list.initialized = true; + if unsafe { + UpdateProcThreadAttribute( + list.ptr(), + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + console as *const _, + size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(list) + } + + fn ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.storage.as_mut_ptr().cast() + } +} + +impl Drop for Attributes { + fn drop(&mut self) { + if self.initialized { + unsafe { DeleteProcThreadAttributeList(self.ptr()) }; + } + } +} + +fn dimensions(cols: u16, rows: u16) -> io::Result { + if cols == 0 || rows == 0 || cols > i16::MAX as u16 || rows > i16::MAX as u16 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid ConPTY dimensions", + )); + } + Ok(COORD { + X: cols as i16, + Y: rows as i16, + }) +} + +pub(crate) fn spawn_in_pty(req: &PtySpawnRequest) -> io::Result { + let size = dimensions(req.cols, req.rows)?; + let mut command = Command::new(&req.file); + command + .args(&req.args) + .env_clear() + .envs(req.env.iter().cloned()); + let mut line = command_line(&command)?; + let environment = environment_block(&command, true)?; + let cwd = req.cwd.as_ref().map(|s| wide_nul(s.as_ref())).transpose()?; + + let (input_read, mut input_write) = pipe()?; + let (output_read, output_write) = pipe()?; + let mut console = Console(0); + let hr = unsafe { + CreatePseudoConsole( + size, + input_read.as_raw_handle(), + output_write.as_raw_handle(), + 0, + &mut console.0, + ) + }; + if hr < 0 { + return Err(io::Error::other(format!( + "CreatePseudoConsole failed: 0x{:08x}", + hr as u32 + ))); + } + let mut attrs = Attributes::console(console.0)?; + let mut startup: STARTUPINFOEXW = unsafe { zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + // Explicit null standard handles let ConPTY install its console handles. + // Otherwise a host launched with redirected stdin can pass that EOF pipe + // through to cmd.exe, which immediately exits instead of reading the PTY. + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.lpAttributeList = attrs.ptr(); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + if unsafe { + CreateProcessW( + std::ptr::null(), + line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + environment.as_ptr().cast(), + cwd.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), + &startup.StartupInfo, + &mut info, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + unsafe { CloseHandle(info.hThread) }; + let process = unsafe { OwnedHandle::from_raw_handle(info.hProcess) }; + // ConPTY holds its own references. Retaining these ends prevents EOF. + drop(input_read); + drop(output_write); + let (input, pending) = mpsc::channel::>(); + std::thread::spawn(move || { + for bytes in pending { + if input_write.write_all(&bytes).is_err() { + break; + } + } + }); + Ok(PtyChild { + pid: info.dwProcessId as i32, + master: Arc::new(PtySession { + console: Mutex::new(console), + process, + output: output_read, + input: Mutex::new(Some(input)), + }), + }) +} + +pub(crate) fn read_pty(session: &PtySession, bytes: &mut [u8]) -> io::Result { + (&session.output).read(bytes) +} + +pub(crate) fn write_pty(session: &PtySession, bytes: &[u8]) -> bool { + session + .input + .lock() + .unwrap() + .as_ref() + .is_some_and(|tx| tx.send(bytes.to_vec()).is_ok()) +} + +pub(crate) fn resize_pty(session: Arc, cols: u16, rows: u16) -> bool { + let Ok(size) = dimensions(cols, rows) else { + return false; + }; + let console = session.console.lock().unwrap(); + console.0 != 0 && unsafe { ResizePseudoConsole(console.0, size) } >= 0 +} + +pub(crate) fn signal_pty(session: &PtySession, signal: i32) -> bool { + let process = session.process.as_raw_handle(); + if unsafe { WaitForSingleObject(process, 0) } != WAIT_TIMEOUT { + return false; + } + signal == 0 || unsafe { TerminateProcess(process, 1) } != 0 +} + +pub(crate) fn wait_child(session: Arc) -> (Option, Option) { + let process = session.process.as_raw_handle(); + let mut code = 1; + if unsafe { WaitForSingleObject(process, INFINITE) } == WAIT_OBJECT_0 { + unsafe { GetExitCodeProcess(process, &mut code) }; + } + session.input.lock().unwrap().take(); + let console = { + let mut guard = session.console.lock().unwrap(); + std::mem::replace(&mut *guard, Console(0)) + }; + // Terminates any remaining attached descendants and flushes the final + // output frame. The separate reader must remain active until EOF. + drop(console); + (Some(code as i32), None) +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; diff --git a/crates/perry-runtime/src/pty/windows_tests.rs b/crates/perry-runtime/src/pty/windows_tests.rs new file mode 100644 index 0000000000..8348dcb6c3 --- /dev/null +++ b/crates/perry-runtime/src/pty/windows_tests.rs @@ -0,0 +1,228 @@ +use super::*; +use std::time::Duration; + +#[test] +fn console_fixture() { + if std::env::var("PERRY_8512_CONSOLE_FIXTURE").as_deref() != Ok("1") { + return; + } + use std::io::BufRead; + use windows_sys::Win32::System::Console::{ + GetConsoleScreenBufferInfo, GetStdHandle, CONSOLE_SCREEN_BUFFER_INFO, STD_OUTPUT_HANDLE, + }; + println!("CONSOLE_READY"); + for line in std::io::stdin().lock().lines() { + match line.unwrap().trim() { + "size" => { + let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { zeroed() }; + assert_ne!( + unsafe { + GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &mut info) + }, + 0 + ); + println!("CONSOLE_SIZE:{}x{}", info.dwSize.X, info.dwSize.Y); + } + "tree" => { + let mut child = Command::new("ping.exe") + .args(["-n", "30", "127.0.0.1"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + println!("CONSOLE_CHILD:{}:END", child.id()); + child.wait().unwrap(); + } + _ => {} + } + } +} + +fn request() -> PtySpawnRequest { + PtySpawnRequest { + file: std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into()), + args: vec!["/d".into(), "/q".into()], + env: std::env::vars().collect(), + cwd: None, + cols: 80, + rows: 24, + } +} + +fn drain(session: Arc) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut output = Vec::new(); + let mut buf = [0; 8192]; + loop { + match read_pty(&session, &mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => output.extend_from_slice(&buf[..n]), + } + } + let _ = tx.send(String::from_utf8_lossy(&output).into_owned()); + }); + rx +} + +fn wait(session: Arc) -> mpsc::Receiver<(Option, Option)> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(wait_child(session)); + }); + rx +} + +#[test] +fn conpty_echo_resize_cwd_env_exit() { + let mut req = request(); + req.env + .push(("PERRY_PTY_MARKER".into(), "native_8512_roundtrip".into())); + let cwd = std::env::temp_dir(); + req.cwd = Some(cwd.to_string_lossy().into_owned()); + let child = spawn_in_pty(&req).expect("spawn ConPTY shell"); + let output = drain(child.master.clone()); + let exited = wait(child.master.clone()); + assert!(resize_pty(child.master.clone(), 120, 40)); + assert!(!resize_pty(child.master.clone(), 0, 40)); + assert!(write_pty( + &child.master, + b"echo %PERRY_PTY_MARKER%\r\ncd\r\nexit 7\r\n" + )); + let status = exited.recv_timeout(Duration::from_secs(15)); + if status.is_err() { + signal_pty(&child.master, 9); + } + let output = output + .recv_timeout(Duration::from_secs(5)) + .expect("final frame and EOF"); + assert_eq!(status.unwrap(), (Some(7), None), "{output:?}"); + assert!(output.contains("native_8512_roundtrip"), "{output:?}"); + assert!( + output + .to_lowercase() + .contains(&cwd.to_string_lossy().trim_end_matches('\\').to_lowercase()), + "{output:?}" + ); + assert!( + !signal_pty(&child.master, 9), + "must not act on a reaped process" + ); + assert!(!resize_pty(child.master.clone(), 80, 24)); +} + +#[test] +fn conpty_kill_closes_streams_and_process() { + let mut req = request(); + req.file = "ping.exe".into(); + req.args = vec!["-n".into(), "30".into(), "127.0.0.1".into()]; + let child = spawn_in_pty(&req).expect("spawn ConPTY ping"); + let output = drain(child.master.clone()); + let exited = wait(child.master.clone()); + assert!(signal_pty(&child.master, 0)); + assert!(signal_pty(&child.master, 15)); + assert_eq!( + exited.recv_timeout(Duration::from_secs(15)).unwrap(), + (Some(1), None) + ); + output + .recv_timeout(Duration::from_secs(5)) + .expect("kill must close output"); + assert!(!signal_pty(&child.master, 0)); +} + +#[test] +fn conpty_spawn_errors_are_synchronous() { + let mut req = request(); + req.file = "perry-nonexistent-8512.exe".into(); + assert!(spawn_in_pty(&req).is_err()); + req = request(); + req.cwd = Some("Z:\\perry-nonexistent-8512".into()); + assert!(spawn_in_pty(&req).is_err()); + req = request(); + req.args.push("invalid\0argument".into()); + assert!(spawn_in_pty(&req).is_err()); +} + +#[test] +fn conpty_dimensions_reach_child_and_kill_reaps_attached_tree() { + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE}; + let mut req = request(); + req.file = std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(); + req.args = [ + "--exact", + "pty::native::tests::console_fixture", + "--nocapture", + ] + .map(str::to_string) + .to_vec(); + req.env + .push(("PERRY_8512_CONSOLE_FIXTURE".into(), "1".into())); + let child = spawn_in_pty(&req).unwrap(); + let session = child.master.clone(); + let (tx, output) = mpsc::channel(); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match read_pty(&session, &mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx + .send(String::from_utf8_lossy(&buf[..n]).into_owned()) + .is_err() + { + break; + } + } + } + } + }); + let exited = wait(child.master.clone()); + let mut text = String::new(); + let mut expect = |marker: &str| { + while !text.contains(marker) { + match output.recv_timeout(Duration::from_secs(10)) { + Ok(chunk) => text.push_str(&chunk), + Err(err) => { + signal_pty(&child.master, 9); + panic!("{marker}: {err}: {text:?}"); + } + } + } + }; + expect("CONSOLE_READY"); + assert!(resize_pty(child.master.clone(), 120, 40)); + assert!(write_pty(&child.master, b"size\r")); + expect("CONSOLE_SIZE:120x40"); + assert!(write_pty(&child.master, b"tree\r")); + expect("CONSOLE_CHILD:"); + // Wait for the terminator if a read split the decimal pid. + expect(":END"); + let suffix = text.split("CONSOLE_CHILD:").last().unwrap(); + let pid: u32 = suffix + .chars() + .take_while(char::is_ascii_digit) + .collect::() + .parse() + .unwrap(); + let descendant = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + assert!(!descendant.is_null()); + let descendant = unsafe { OwnedHandle::from_raw_handle(descendant) }; + assert_eq!( + unsafe { WaitForSingleObject(descendant.as_raw_handle(), 0) }, + WAIT_TIMEOUT + ); + assert!(signal_pty(&child.master, 15)); + assert_eq!( + exited.recv_timeout(Duration::from_secs(10)).unwrap(), + (Some(1), None) + ); + assert_eq!( + unsafe { WaitForSingleObject(descendant.as_raw_handle(), 5000) }, + WAIT_OBJECT_0 + ); +} diff --git a/crates/perry/tests/issue_8512_pty_process.rs b/crates/perry/tests/issue_8512_pty_process.rs new file mode 100644 index 0000000000..2e3523344f --- /dev/null +++ b/crates/perry/tests/issue_8512_pty_process.rs @@ -0,0 +1,77 @@ +//! Compiled TypeScript reaches the shared native PTY through bun-pty, including +//! on headless Windows runners where the compiler's stdin is redirected. +#![cfg(any(unix, windows))] + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn bun_pty_compiled_interactive_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let entry = dir.path().join("main.ts"); + let output = dir + .path() + .join(if cfg!(windows) { "main.exe" } else { "main" }); + std::fs::write(&entry, r#" +import { spawn } from "bun-pty"; +const windows = process.platform === "win32"; +const term = spawn(windows ? "cmd.exe" : "sh", windows ? ["/d", "/q"] : [], { + name: "xterm-256color", cols: 80, rows: 24, cwd: process.cwd(), env: process.env, +}); +let text = ""; +term.onData((chunk: string) => { text += chunk; }); +term.onExit((event: { exitCode: number }) => { + if (!text.includes("native_ok")) throw new Error("PTY output missing: " + text); + if (event.exitCode !== 0) throw new Error("unexpected exit code"); + console.log("PTY_OK"); +}); +term.resize(100, 40); +if (term.cols !== 100 || term.rows !== 40) throw new Error("resize failed"); +term.pause(); +term.write(windows ? "set part=ok\r\necho native_%part%\r\nexit\r\n" : "echo native_$(echo ok)\nexit\n"); +setTimeout(() => term.resume(), 100); +"#).unwrap(); + let compile = Command::new(env!("CARGO_BIN_EXE_perry")) + .args(["compile", "--no-auto-optimize"]) + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .unwrap(); + assert!( + compile.status.success(), + "{}\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let mut child = Command::new(&output) + .current_dir(dir.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if child.try_wait().unwrap().is_some() { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("PTY fixture did not terminate"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let run = child.wait_with_output().unwrap(); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + assert!( + String::from_utf8_lossy(&run.stdout).contains("PTY_OK"), + "{}", + String::from_utf8_lossy(&run.stdout) + ); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index fe1e3d4063..21bee5fb5e 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -307,7 +307,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -324,7 +324,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "40a1d0744405a0c2e71ae578a0767ec0fb0100aeeecdbd877c94658af6067169", "crates/perry-runtime/src/gc/cycle.rs": "77eadaf7c4157308c3b14be5e1ff11d7198b84b503a50ff2d244851248d3e800", - "crates/perry-runtime/src/gc/mod.rs": "db54308d6acf29c7ad4f7b360490976d82329ba0667dad9c6a732d1112548c04", + "crates/perry-runtime/src/gc/mod.rs": "1b0b52886647633257c3267122082f80b4b9eb8215e8b0894cf5fbae86a95b44", "crates/perry-runtime/src/gc/policy.rs": "a701257f2e2310adabe16e33c0afcd935c7cd28e1ddc157b4974b48e2688cc4f", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } From 2daabd9122cc2095ce0092be62cd7e116b84471f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:47:59 +0200 Subject: [PATCH 31/36] chore: key PTY changeset to PR 10132 (cherry picked from commit 4ca27fdd224e50ce5459d845cba9894844548570) --- .../{8512-native-pty-process.md => 10132-native-pty-process.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{8512-native-pty-process.md => 10132-native-pty-process.md} (100%) diff --git a/changelog.d/8512-native-pty-process.md b/changelog.d/10132-native-pty-process.md similarity index 100% rename from changelog.d/8512-native-pty-process.md rename to changelog.d/10132-native-pty-process.md From 3ba7dd4c121496ef4834d2299978d8f32add8936 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 15:52:29 +0200 Subject: [PATCH 32/36] test: mark PTY and process fixture thread locals as test-only (cherry picked from commit 1cffa9126c7d2b5fbc7bd95e587dd0d2902ced77) --- .../perry-runtime/src/child_process/reactor/lifecycle_tests.rs | 1 + crates/perry-runtime/src/pty/tests.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs b/crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs index f6f343fc16..18713532da 100644 --- a/crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs +++ b/crates/perry-runtime/src/child_process/reactor/lifecycle_tests.rs @@ -4,6 +4,7 @@ use std::cell::RefCell; use std::io::BufRead; use std::time::Instant; +#[cfg(test)] thread_local! { static EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; static OUTPUT: RefCell> = const { RefCell::new(Vec::new()) }; diff --git a/crates/perry-runtime/src/pty/tests.rs b/crates/perry-runtime/src/pty/tests.rs index 1ec1a65f98..39fdccd902 100644 --- a/crates/perry-runtime/src/pty/tests.rs +++ b/crates/perry-runtime/src/pty/tests.rs @@ -6,6 +6,7 @@ use crate::closure::ClosureHeader; use std::cell::{Cell, RefCell}; use std::time::{Duration, Instant}; +#[cfg(test)] thread_local! { static OUTPUT: RefCell = const { RefCell::new(String::new()) }; static EXITS: RefCell> = const { RefCell::new(Vec::new()) }; From df9c0a65ae4e5d6a92d78841daa4c594caff9ffa Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 16:01:22 +0200 Subject: [PATCH 33/36] fix(compile): support OpenTUI Solid universal JSX (cherry picked from commit dcf3a1d309dd929973fa9571ea0e71d006c81d75) --- Cargo.lock | 2 + changelog.d/10099-solid-universal.md | 11 + crates/perry-hir/Cargo.toml | 4 + crates/perry-hir/examples/solid_jsx.rs | 29 + crates/perry-hir/src/solid_jsx.rs | 180 ++- crates/perry-hir/src/solid_jsx/native.rs | 289 ++++ crates/perry-hir/src/solid_jsx/tests.rs | 104 ++ crates/perry/src/commands/compile.rs | 3 +- .../perry/src/commands/compile/bootstrap.rs | 6 +- .../perry/src/commands/compile/build_cache.rs | 5 +- .../src/commands/compile/collect_modules.rs | 13 +- .../perry/src/commands/compile/host_config.rs | 13 +- .../perry/src/commands/compile/init_order.rs | 20 +- crates/perry/src/commands/compile/resolve.rs | 11 +- .../src/commands/compile/resolve/solid.rs | 83 + .../commands/compile/resolve/solid/tests.rs | 89 ++ .../compile/resolve/tsconfig_paths.rs | 38 +- .../src/commands/compile/run_pipeline.rs | 205 +-- .../src/commands/compile/solid_config.rs | 68 + .../commands/compile/solid_config/tests.rs | 87 ++ crates/perry/src/commands/compile/types.rs | 12 +- crates/perry/tests/solid_jsx_config.rs | 135 +- docs/src/getting-started/project-config.md | 24 + packages/perry-solid/README.md | 12 +- .../release/packages/opentui-solid/.gitignore | 2 + .../release/packages/opentui-solid/README.md | 24 + .../release/packages/opentui-solid/compare.py | 85 + .../packages/opentui-solid/expected.txt | 1 + .../release/packages/opentui-solid/fixture.sh | 16 + .../release/packages/opentui-solid/frames.tsx | 25 + tests/release/packages/opentui-solid/host.ts | 40 + tests/release/packages/opentui-solid/main.tsx | 93 ++ .../release/packages/opentui-solid/oracle.mjs | 10 + .../packages/opentui-solid/package-lock.json | 1380 +++++++++++++++++ .../packages/opentui-solid/package.json | 12 + .../packages/opentui-solid/tsconfig.json | 3 + 36 files changed, 2890 insertions(+), 244 deletions(-) create mode 100644 changelog.d/10099-solid-universal.md create mode 100644 crates/perry-hir/examples/solid_jsx.rs create mode 100644 crates/perry-hir/src/solid_jsx/native.rs create mode 100644 crates/perry-hir/src/solid_jsx/tests.rs create mode 100644 crates/perry/src/commands/compile/resolve/solid.rs create mode 100644 crates/perry/src/commands/compile/resolve/solid/tests.rs create mode 100644 crates/perry/src/commands/compile/solid_config.rs create mode 100644 crates/perry/src/commands/compile/solid_config/tests.rs create mode 100644 tests/release/packages/opentui-solid/.gitignore create mode 100644 tests/release/packages/opentui-solid/README.md create mode 100644 tests/release/packages/opentui-solid/compare.py create mode 100644 tests/release/packages/opentui-solid/expected.txt create mode 100755 tests/release/packages/opentui-solid/fixture.sh create mode 100644 tests/release/packages/opentui-solid/frames.tsx create mode 100644 tests/release/packages/opentui-solid/host.ts create mode 100644 tests/release/packages/opentui-solid/main.tsx create mode 100644 tests/release/packages/opentui-solid/oracle.mjs create mode 100644 tests/release/packages/opentui-solid/package-lock.json create mode 100644 tests/release/packages/opentui-solid/package.json create mode 100644 tests/release/packages/opentui-solid/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index 9ac95c834e..fe75bff02f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6371,6 +6371,8 @@ dependencies = [ "stacker", "swc_common", "swc_ecma_ast", + "swc_ecma_codegen", + "swc_ecma_transforms_base", "swc_ecma_visit", "thiserror 1.0.69", ] diff --git a/changelog.d/10099-solid-universal.md b/changelog.d/10099-solid-universal.md new file mode 100644 index 0000000000..dcb47d920b --- /dev/null +++ b/changelog.d/10099-solid-universal.md @@ -0,0 +1,11 @@ +Compile OpenTUI's Solid universal JSX ahead of time using the nearest +`tsconfig.json` `jsxImportSource`, or an explicit `perry.jsx.runtime`. Preserve +the `"solid"` and `"default"` modes. Native JSX now emits static properties, +tracked property effects, text nodes, insertion markers, refs and directives; +components preserve dynamic getters, render props and ordered spreads. + +Select Solid's reactive core/store builds throughout the compilation graph and +honor OpenTUI's Bun entry when `--platform bun` is enabled. Configuration cache +inputs include per-source and inherited JSX settings. Add runtime-selection +tests and a pinned differential fixture using OpenTUI's real Babel transform, +Solid's universal renderer and OpenTUI's character-frame renderer. diff --git a/crates/perry-hir/Cargo.toml b/crates/perry-hir/Cargo.toml index b505dab798..ca37714a05 100644 --- a/crates/perry-hir/Cargo.toml +++ b/crates/perry-hir/Cargo.toml @@ -21,9 +21,13 @@ perry-parser.workspace = true swc_ecma_ast.workspace = true swc_common.workspace = true swc_ecma_visit.workspace = true +swc_ecma_transforms_base.workspace = true thiserror.workspace = true anyhow.workspace = true serde = { workspace = true } serde_json = { workspace = true } stacker.workspace = true + +[dev-dependencies] +swc_ecma_codegen.workspace = true diff --git a/crates/perry-hir/examples/solid_jsx.rs b/crates/perry-hir/examples/solid_jsx.rs new file mode 100644 index 0000000000..9714ed864f --- /dev/null +++ b/crates/perry-hir/examples/solid_jsx.rs @@ -0,0 +1,29 @@ +//! Emit the universal expansion for differential testing with a real renderer. +//! cargo run -p perry-hir --example solid_jsx -- input.tsx runtime output.ts + +use swc_ecma_visit::VisitMutWith; + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + anyhow::ensure!( + args.len() == 4, + "usage: solid_jsx input.tsx runtime output.ts" + ); + let source = std::fs::read_to_string(&args[1])?; + let module = perry_parser::parse_typescript(&source, &args[1])?; + let mut module = perry_hir::solid_jsx::lower_solid_jsx(&module, &args[2]).unwrap_or(module); + // Exercise the normal HIR path as well as emitting runnable oracle input. + let hir = perry_hir::lower_module(&module, "solid_oracle", &args[1])?; + let hir = format!("{hir:?}"); + for name in ["jsx", "jsxs"] { + anyhow::ensure!( + !hir.contains(&format!("ExternFuncRef {{ name: {name:?},")), + "universal JSX left a fallback {name} call in HIR" + ); + } + // SWC's printer expects the usual post-transform precedence/paren fixup. + module.visit_mut_with(&mut swc_ecma_transforms_base::fixer::fixer(None)); + let output = swc_ecma_codegen::to_code(&module); + std::fs::write(&args[3], output)?; + Ok(()) +} diff --git a/crates/perry-hir/src/solid_jsx.rs b/crates/perry-hir/src/solid_jsx.rs index 67910401de..3e431d1231 100644 --- a/crates/perry-hir/src/solid_jsx.rs +++ b/crates/perry-hir/src/solid_jsx.rs @@ -5,10 +5,14 @@ use std::collections::BTreeSet; -use swc_common::{Spanned, DUMMY_SP}; +use swc_common::{Globals, Mark, Spanned, DUMMY_SP, GLOBALS}; use swc_ecma_ast as ast; use swc_ecma_visit::{Visit, VisitMut, VisitMutWith, VisitWith}; +mod native; +#[cfg(test)] +mod tests; + /// Expand JSX for an explicitly selected universal renderer. Returns `None` /// without cloning when the module contains no JSX. pub fn lower_solid_jsx(module: &ast::Module, runtime: &str) -> Option { @@ -39,13 +43,59 @@ pub fn lower_solid_jsx(module: &ast::Module, runtime: &str) -> Option); + impl Visit for Immutable { + fn visit_var_decl(&mut self, decl: &ast::VarDecl) { + if decl.kind == ast::VarDeclKind::Const { + struct Bindings<'a>(&'a mut BTreeSet); + impl Visit for Bindings<'_> { + fn visit_binding_ident(&mut self, ident: &ast::BindingIdent) { + self.0.insert(ident.id.to_id()); + } + fn visit_expr(&mut self, _: &ast::Expr) {} + } + for decl in &decl.decls { + decl.name.visit_with(&mut Bindings(&mut self.0)); + } + } + decl.visit_children_with(self); + } + fn visit_import_decl(&mut self, import: &ast::ImportDecl) { + for specifier in &import.specifiers { + self.0.insert(specifier.local().to_id()); + } + } + } + let mut immutable = Immutable(BTreeSet::new()); + result.visit_with(&mut immutable); + immutable.0 + }); let mut lowering = SolidJsx { prefix, next: 0, helpers: BTreeSet::new(), + immutable, }; - let mut result = module.clone(); result.visit_mut_with(&mut lowering); + // These resolver contexts belong to the temporary SWC Globals above. HIR + // resolves lexical names itself, so do not leak temporary hygiene IDs. + struct ClearContexts; + impl VisitMut for ClearContexts { + fn visit_mut_syntax_context(&mut self, context: &mut swc_common::SyntaxContext) { + *context = Default::default(); + } + } + result.visit_mut_with(&mut ClearContexts); if lowering.helpers.is_empty() { return Some(result); } @@ -83,6 +133,7 @@ struct SolidJsx { prefix: String, next: usize, helpers: BTreeSet, + immutable: BTreeSet, } fn ident(name: &str) -> ast::Ident { @@ -98,6 +149,14 @@ fn string(value: &str) -> ast::Expr { } fn call(callee: ast::Expr, args: Vec) -> ast::Expr { + let callee = if matches!(callee, ast::Expr::Arrow(_) | ast::Expr::Fn(_)) { + ast::Expr::Paren(ast::ParenExpr { + span: DUMMY_SP, + expr: Box::new(callee), + }) + } else { + callee + }; ast::Expr::Call(ast::CallExpr { callee: ast::Callee::Expr(Box::new(callee)), args: args.into_iter().map(|expr| expr.into()).collect(), @@ -191,11 +250,31 @@ fn array(elements: Vec) -> ast::Expr { }) } -fn is_static_value(expr: &ast::Expr) -> bool { - matches!( - expr, - ast::Expr::Lit(_) | ast::Expr::Arrow(_) | ast::Expr::Fn(_) - ) +fn is_dynamic(expr: &ast::Expr) -> bool { + struct Dynamic(bool); + impl Visit for Dynamic { + fn visit_expr(&mut self, expr: &ast::Expr) { + match expr { + // A render prop or handler is a value; its body runs later. + ast::Expr::Arrow(_) | ast::Expr::Fn(_) => {} + ast::Expr::Call(_) + | ast::Expr::Member(_) + | ast::Expr::OptChain(_) + | ast::Expr::TaggedTpl(_) + | ast::Expr::JSXElement(_) + | ast::Expr::JSXFragment(_) => self.0 = true, + ast::Expr::Bin(binary) if binary.op == ast::BinaryOp::In => self.0 = true, + _ => expr.visit_children_with(self), + } + } + fn visit_spread_element(&mut self, _: &ast::SpreadElement) { + self.0 = true; + } + fn visit_function(&mut self, _: &ast::Function) {} + } + let mut dynamic = Dynamic(false); + expr.visit_with(&mut dynamic); + dynamic.0 } fn contains_jsx(expr: &ast::Expr) -> bool { @@ -333,38 +412,55 @@ impl SolidJsx { } fn ref_value(&mut self, value: ast::Expr) -> ast::Expr { - let target = ast::AssignTarget::try_from(Box::new(value.clone())).ok(); + let mut value = value; + loop { + value = match value { + ast::Expr::TsAs(expr) => *expr.expr, + ast::Expr::TsNonNull(expr) => *expr.expr, + ast::Expr::TsTypeAssertion(expr) => *expr.expr, + ast::Expr::Paren(expr) => *expr.expr, + _ => break, + }; + } + let immutable = + matches!(&value, ast::Expr::Ident(id) if self.immutable.contains(&id.to_id())); + let target = if immutable { + None + } else { + ast::AssignTarget::try_from(Box::new(value.clone())).ok() + }; let node = self.temporary(); let current = self.temporary(); let invoke = call( ast::Expr::Ident(current.clone()), vec![ast::Expr::Ident(node.clone())], ); - let action = if let Some(target) = target { + let fallback = if let Some(target) = target { let assign = ast::Expr::Assign(ast::AssignExpr { span: DUMMY_SP, op: ast::AssignOp::Assign, left: target, right: Box::new(ast::Expr::Ident(node.clone())), }); - ast::Expr::Cond(ast::CondExpr { + assign + } else { + ast::Expr::Ident(ident("undefined")) + }; + let action = ast::Expr::Cond(ast::CondExpr { + span: DUMMY_SP, + test: Box::new(ast::Expr::Bin(ast::BinExpr { span: DUMMY_SP, - test: Box::new(ast::Expr::Bin(ast::BinExpr { + op: ast::BinaryOp::EqEqEq, + left: Box::new(ast::Expr::Unary(ast::UnaryExpr { span: DUMMY_SP, - op: ast::BinaryOp::EqEqEq, - left: Box::new(ast::Expr::Unary(ast::UnaryExpr { - span: DUMMY_SP, - op: ast::UnaryOp::TypeOf, - arg: Box::new(ast::Expr::Ident(current.clone())), - })), - right: Box::new(string("function")), + op: ast::UnaryOp::TypeOf, + arg: Box::new(ast::Expr::Ident(current.clone())), })), - cons: Box::new(invoke), - alt: Box::new(assign), - }) - } else { - invoke - }; + right: Box::new(string("function")), + })), + cons: Box::new(invoke), + alt: Box::new(fallback), + }); let callback = ast::Expr::Arrow(ast::ArrowExpr { body: Box::new(ast::BlockStmtOrExpr::BlockStmt(ast::BlockStmt { stmts: vec![binding(current, value), statement(action)], @@ -397,7 +493,7 @@ impl SolidJsx { let value = *expr.clone(); Some( if native - && !is_static_value(&value) + && is_dynamic(&value) && !matches!( value, ast::Expr::JSXElement(_) | ast::Expr::JSXFragment(_) @@ -423,6 +519,9 @@ impl SolidJsx { fn element(&mut self, element: &ast::JSXElement) -> ast::Expr { let (name, native) = self.element_name(&element.opening.name); + if native { + return self.native_element(element); + } let mut chunks = Vec::new(); let mut props = Vec::new(); let mut has_spread = false; @@ -433,8 +532,9 @@ impl SolidJsx { if !props.is_empty() { chunks.push(object(std::mem::take(&mut props))); } + let dynamic = is_dynamic(&spread.expr); let source = self.expression(*spread.expr.clone()); - chunks.push(arrow(source)); + chunks.push(if dynamic { arrow(source) } else { source }); } ast::JSXAttrOrSpread::JSXAttr(attribute) => { let key = match &attribute.name { @@ -456,7 +556,7 @@ impl SolidJsx { if key == "ref" { value = self.ref_value(value); } - let getter = !is_static_value(&value); + let getter = is_dynamic(&value); props.push(property(&key, value, getter)); } } @@ -472,7 +572,7 @@ impl SolidJsx { } else { array(children) }; - let getter = !native && !is_static_value(&children); + let getter = is_dynamic(&children) || matches!(children, ast::Expr::Array(_)); props.push(property("children", children, getter)); } if !props.is_empty() || chunks.is_empty() { @@ -483,17 +583,7 @@ impl SolidJsx { } else { self.helper("mergeProps", chunks) }; - if native { - let node = self.temporary(); - let create = self.helper("createElement", vec![name]); - let spread = self.helper("spread", vec![ast::Expr::Ident(node.clone()), props]); - block_expr( - vec![binding(node.clone(), create), statement(spread)], - ast::Expr::Ident(node), - ) - } else { - self.helper("createComponent", vec![name, props]) - } + self.helper("createComponent", vec![name, props]) } fn fragment(&mut self, fragment: &ast::JSXFragment) -> ast::Expr { @@ -501,7 +591,17 @@ impl SolidJsx { fragment .children .iter() - .filter_map(|child| self.child(child, true)) + .filter_map(|child| { + let value = self.child(child, true)?; + // Dynamic fragment entries are memo accessors, while literal + // function children remain values (e.g. a render prop). + let dynamic = match child { + ast::JSXElementChild::JSXExprContainer(c) => matches!(&c.expr, ast::JSXExpr::Expr(e) if is_dynamic(e) && !matches!(**e, ast::Expr::JSXElement(_) | ast::Expr::JSXFragment(_))), + ast::JSXElementChild::JSXSpreadChild(_) => true, + _ => false, + }; + Some(if dynamic { self.helper("memo", vec![value]) } else { value }) + }) .collect(), ) } diff --git a/crates/perry-hir/src/solid_jsx/native.rs b/crates/perry-hir/src/solid_jsx/native.rs new file mode 100644 index 0000000000..b679f16cb7 --- /dev/null +++ b/crates/perry-hir/src/solid_jsx/native.rs @@ -0,0 +1,289 @@ +use super::*; + +struct NativeElement { + node: ast::Ident, + declarations: Vec, + statements: Vec, + dynamics: Vec<(ast::Ident, String, ast::Expr)>, +} + +fn null() -> ast::Expr { + ast::Expr::Lit(ast::Lit::Null(ast::Null { span: DUMMY_SP })) +} + +fn bool_value(value: bool) -> ast::Expr { + ast::Expr::Lit(ast::Lit::Bool(ast::Bool { + span: DUMMY_SP, + value, + })) +} + +fn field(object: &ast::Ident, key: &str) -> ast::Expr { + ast::Expr::Member(ast::MemberExpr { + span: DUMMY_SP, + obj: Box::new(ast::Expr::Ident(object.clone())), + prop: ast::MemberProp::Ident(ast::IdentName::new(key.into(), DUMMY_SP)), + }) +} + +fn text_child(child: &ast::JSXElementChild) -> Option { + match child { + ast::JSXElementChild::JSXText(text) => Some(crate::jsx::normalize_jsx_text(&text.value)), + ast::JSXElementChild::JSXExprContainer(container) => match &container.expr { + ast::JSXExpr::Expr(expr) => match &**expr { + ast::Expr::Lit(ast::Lit::Str(s)) => Some(s.value.to_string_lossy().into_owned()), + ast::Expr::Lit(ast::Lit::Num(n)) => Some(n.value.to_string()), + _ => None, + }, + _ => None, + }, + _ => None, + } +} + +impl SolidJsx { + pub(super) fn native_element(&mut self, element: &ast::JSXElement) -> ast::Expr { + let mut parts = self.native_parts(element); + parts.declarations.append(&mut parts.statements); + if !parts.dynamics.is_empty() { + let effect = self.property_effect(parts.dynamics); + parts.declarations.push(statement(effect)); + } + block_expr(parts.declarations, ast::Expr::Ident(parts.node)) + } + + fn native_parts(&mut self, element: &ast::JSXElement) -> NativeElement { + let node = self.temporary(); + let (name, _) = self.element_name(&element.opening.name); + let create = self.helper("createElement", vec![name]); + let mut result = NativeElement { + node: node.clone(), + declarations: vec![binding(node.clone(), create)], + statements: Vec::new(), + dynamics: Vec::new(), + }; + let has_spread = element + .opening + .attrs + .iter() + .any(|a| matches!(a, ast::JSXAttrOrSpread::SpreadElement(_))); + let has_children = element.children.iter().any(|child| match child { + ast::JSXElementChild::JSXText(t) => { + !crate::jsx::normalize_jsx_text(&t.value).is_empty() + } + ast::JSXElementChild::JSXExprContainer(c) => matches!(c.expr, ast::JSXExpr::Expr(_)), + _ => true, + }); + let mut chunks = Vec::new(); + let mut props = Vec::new(); + let mut children_prop = None; + for attr in &element.opening.attrs { + let attr = match attr { + ast::JSXAttrOrSpread::SpreadElement(spread) => { + if !props.is_empty() { + chunks.push(object(std::mem::take(&mut props))); + } + let dynamic = is_dynamic(&spread.expr); + let source = self.expression(*spread.expr.clone()); + chunks.push(if dynamic { arrow(source) } else { source }); + continue; + } + ast::JSXAttrOrSpread::JSXAttr(attr) => attr, + }; + let key = match &attr.name { + ast::JSXAttrName::Ident(name) => name.sym.to_string(), + ast::JSXAttrName::JSXNamespacedName(name) => { + format!("{}:{}", name.ns.sym, name.name.sym) + } + }; + let value = attr + .value + .as_ref() + .map(|v| self.attribute_value(v)) + .unwrap_or_else(|| bool_value(true)); + if key == "ref" { + let callback = self.ref_value(value); + result.statements.insert( + 0, + statement(call(callback, vec![ast::Expr::Ident(node.clone())])), + ); + } else if let Some(directive) = key.strip_prefix("use:") { + let use_call = self.helper( + "use", + vec![ + ast::Expr::Ident(ident(directive)), + ast::Expr::Ident(node.clone()), + arrow(value), + ], + ); + result.statements.insert(0, statement(use_call)); + } else if has_spread { + let dynamic = is_dynamic(&value); + props.push(property(&key, value, dynamic)); + } else if key == "children" { + // The explicit JSX children win over the children attribute. + if !has_children { + children_prop = Some(if is_dynamic(&value) { + arrow(value) + } else { + value + }); + } + } else if is_dynamic(&value) { + result.dynamics.push((node.clone(), key, value)); + } else { + let set = self.helper( + "setProp", + vec![ast::Expr::Ident(node.clone()), string(&key), value], + ); + result.statements.push(statement(set)); + } + } + if has_spread { + if !props.is_empty() { + chunks.push(object(props)); + } + let props = if chunks.len() == 1 { + chunks.remove(0) + } else { + self.helper("mergeProps", chunks) + }; + let spread = self.helper( + "spread", + vec![ + ast::Expr::Ident(node.clone()), + props, + bool_value(has_children), + ], + ); + result.statements.push(statement(spread)); + } + + // Allocate and attach static siblings before inserting dynamic ranges. + // Each insert uses the next static sibling as its marker; null marks the + // end of a multi-child range, while no marker owns the whole parent. + let mut children: Vec<(Option, Vec, Option)> = Vec::new(); + let mut text = String::new(); + let flush_text = |this: &mut Self, + text: &mut String, + result: &mut NativeElement, + children: &mut Vec<_>| { + if text.is_empty() { + return; + } + let id = this.temporary(); + let create = this.helper("createTextNode", vec![string(text)]); + result.declarations.push(binding(id.clone(), create)); + children.push((Some(id), Vec::new(), None)); + text.clear(); + }; + for child in &element.children { + if let Some(value) = text_child(child) { + text.push_str(&value); + continue; + } + if matches!(child, ast::JSXElementChild::JSXExprContainer(c) if matches!(c.expr, ast::JSXExpr::JSXEmptyExpr(_))) + { + continue; + } + flush_text(self, &mut text, &mut result, &mut children); + if let ast::JSXElementChild::JSXElement(element) = child { + if self.element_name(&element.opening.name).1 { + let mut child = self.native_parts(element); + result.declarations.append(&mut child.declarations); + result.dynamics.append(&mut child.dynamics); + children.push((Some(child.node), child.statements, None)); + continue; + } + } + if let Some(value) = self.child(child, true) { + children.push((None, Vec::new(), Some(value))); + } + } + flush_text(self, &mut text, &mut result, &mut children); + if let Some(value) = children_prop { + children.push((None, Vec::new(), Some(value))); + } + let mut appends = Vec::new(); + for (index, (id, stmts, value)) in children.iter().enumerate() { + if let Some(id) = id { + appends.push(statement(self.helper( + "insertNode", + vec![ast::Expr::Ident(node.clone()), ast::Expr::Ident(id.clone())], + ))); + result.statements.extend(stmts.clone()); + } else if let Some(value) = value { + let mut args = vec![ast::Expr::Ident(node.clone()), value.clone()]; + if children.len() > 1 { + args.push( + children[index + 1..] + .iter() + .find_map(|c| c.0.clone()) + .map(ast::Expr::Ident) + .unwrap_or_else(null), + ); + } + result + .statements + .push(statement(self.helper("insert", args))); + } + } + appends.append(&mut result.statements); + result.statements = appends; + result + } + + fn property_effect(&mut self, dynamics: Vec<(ast::Ident, String, ast::Expr)>) -> ast::Expr { + let previous = self.temporary(); + let mut reads = Vec::new(); + let mut writes = Vec::new(); + let mut initial = Vec::new(); + for (index, (node, key, value)) in dynamics.into_iter().enumerate() { + let current = self.temporary(); + reads.push(binding(current.clone(), value)); + let slot = format!("p{index}"); + let prev = field(&previous, &slot); + initial.push(property(&slot, ast::Expr::Ident(ident("undefined")), false)); + let set = self.helper( + "setProp", + vec![ + ast::Expr::Ident(node), + string(&key), + ast::Expr::Ident(current.clone()), + prev.clone(), + ], + ); + let assign = ast::Expr::Assign(ast::AssignExpr { + span: DUMMY_SP, + op: ast::AssignOp::Assign, + left: ast::AssignTarget::try_from(Box::new(prev.clone())).unwrap(), + right: Box::new(set), + }); + writes.push(ast::Stmt::If(ast::IfStmt { + span: DUMMY_SP, + test: Box::new(ast::Expr::Bin(ast::BinExpr { + span: DUMMY_SP, + op: ast::BinaryOp::NotEqEq, + left: Box::new(ast::Expr::Ident(current)), + right: Box::new(prev), + })), + cons: Box::new(statement(assign)), + alt: None, + })); + } + reads.append(&mut writes); + reads.push(ast::Stmt::Return(ast::ReturnStmt { + span: DUMMY_SP, + arg: Some(Box::new(ast::Expr::Ident(previous.clone()))), + })); + let callback = ast::Expr::Arrow(ast::ArrowExpr { + params: vec![ast::Pat::Ident(previous.into())], + body: Box::new(ast::BlockStmtOrExpr::BlockStmt(ast::BlockStmt { + stmts: reads, + ..Default::default() + })), + ..Default::default() + }); + self.helper("effect", vec![callback, object(initial)]) + } +} diff --git a/crates/perry-hir/src/solid_jsx/tests.rs b/crates/perry-hir/src/solid_jsx/tests.rs new file mode 100644 index 0000000000..1dd2502f7f --- /dev/null +++ b/crates/perry-hir/src/solid_jsx/tests.rs @@ -0,0 +1,104 @@ +use super::*; + +#[test] +fn tracks_reads_but_keeps_identifiers_and_function_bodies_static() { + for (expression, expected) in [ + ("value", false), + ("1 + 2", false), + ("() => value()", false), + ("function() { return props.value }", false), + ("value()", true), + ("props.value", true), + ("props?.value", true), + ("value?.()", true), + ("{ width: count() }", true), + ("'width' in props", true), + ("{ ...props }", true), + ] { + let module = + perry_parser::parse_typescript(&format!("const x = {expression};"), "test.tsx") + .unwrap(); + let ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(var))) = &module.body[0] else { + panic!() + }; + assert_eq!( + is_dynamic(var.decls[0].init.as_ref().unwrap()), + expected, + "{expression}" + ); + } +} + +#[test] +fn universal_helpers_use_selected_module_and_do_not_collide_with_user_names() { + let source = r#" + const __perry_solid_0_createElement = 1; + let ref; + const count = () => 1; + const focus = () => {}; + const x = Hello {count()}; + "#; + let module = perry_parser::parse_typescript(source, "test.tsx").unwrap(); + let expanded = lower_solid_jsx(&module, "@opentui/solid").unwrap(); + let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) = &expanded.body[0] else { + panic!() + }; + assert_eq!(import.src.value, "@opentui/solid"); + assert!(import + .specifiers + .iter() + .all(|s| s.local().sym.starts_with("__perry_solid_1_"))); + let hir = crate::lower_module(&expanded, "test", "test.tsx").unwrap(); + let hir = format!("{hir:?}"); + for helper in [ + "createElement", + "createTextNode", + "insertNode", + "insert", + "setProp", + "effect", + "use", + ] { + assert!(hir.contains(helper), "missing {helper}"); + } + assert!(!hir.contains("JsxElement")); + assert!(!hir.contains("jsx-runtime")); +} + +#[test] +fn modules_without_jsx_do_not_gain_a_renderer_import() { + let module = perry_parser::parse_typescript("export const x = 1", "test.ts").unwrap(); + assert!(lower_solid_jsx(&module, "@opentui/solid").is_none()); +} + +#[test] +fn callback_refs_do_not_assign_constants_but_mutable_shadowed_refs_still_assign() { + let module = perry_parser::parse_typescript( + r#" + import { importedRef } from "./refs"; + const ref = node => {}; + const a = ; + const b = ; + function nested() { let ref; const c = ; return ref; } + "#, + "refs.tsx", + ) + .unwrap(); + let expanded = lower_solid_jsx(&module, "@opentui/solid").unwrap(); + struct Assignments(Vec); + impl Visit for Assignments { + fn visit_assign_expr(&mut self, expr: &ast::AssignExpr) { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(id)) = &expr.left { + self.0.push(id.id.sym.to_string()); + } + expr.visit_children_with(self); + } + } + let mut assignments = Assignments(Vec::new()); + expanded.visit_with(&mut assignments); + assert_eq!( + assignments.0, + vec!["ref"], + "only the nested mutable ref is assigned" + ); +} diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 9f632a997c..4b10f5024f 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -50,6 +50,7 @@ mod post_link; mod precompile_capture; mod reachability; mod size_report; +mod solid_config; mod typed_feedback_profile; mod update_config; mod windows_target; @@ -115,7 +116,7 @@ use resolve::{ ergonomic_export_alias, extract_compile_package_dir, has_perry_native_library, is_declaration_file, is_in_compile_package, is_in_perry_native_package, is_js_file, is_recognized_text_asset, parse_native_library_manifest, parse_package_specifier, - resolve_import_with_bunfs, + resolve_import_with_context, }; pub(crate) use runtime_compat::{ ensure_runtime_library_compatible, runtime_library_diagnostic, runtime_library_status, diff --git a/crates/perry/src/commands/compile/bootstrap.rs b/crates/perry/src/commands/compile/bootstrap.rs index 94f6ef0c02..7642feccc3 100644 --- a/crates/perry/src/commands/compile/bootstrap.rs +++ b/crates/perry/src/commands/compile/bootstrap.rs @@ -201,9 +201,13 @@ pub(super) fn rerun_collect_with_class_field_types( changed |= entry.extend_from(&parent_accessors); } } - if field_map.is_empty() && accessor_map.is_empty() { + if field_map.is_empty() && accessor_map.is_empty() && !ctx.solid_client_recollect { return Ok(()); } + if ctx.solid_client_recollect { + ctx.resolve_cache.clear(); + ctx.solid_client_recollect = false; + } ctx.cross_module_class_field_types = field_map; ctx.cross_module_class_accessors = accessor_map; ctx.native_modules.clear(); diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 8f010880ad..a027c99175 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -1078,10 +1078,13 @@ fn config_inputs_for( } } for source in sources { + out.extend(super::resolve::tsconfig_paths::jsx_config(Path::new(&source.path)).1); let mut dir = PathBuf::from(&source.path); dir.pop(); loop { - for name in ["package.json", "perry.json", "perry.toml"] { + // Union of both sides: each change here is a cache KEY, so + // dropping either entry leaves a stale build when that file moves. + for name in ["package.json", "perry.json", "perry.toml", "tsconfig.json"] { let candidate = dir.join(name); if candidate.exists() { out.insert(candidate); diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 8343aa43fd..67c7490997 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -668,10 +668,15 @@ fn collect_module_one( }); // Expand only in the selected mode. Ordinary accessor/closure lowering then // owns captures, source-order semantics and the generated renderer imports. - let solid_module = ctx - .solid_jsx - .then(|| perry_hir::solid_jsx::lower_solid_jsx(ast_module, "perry-solid")) - .flatten(); + let solid_runtime = ctx.solid_jsx.runtime_for(entry_path)?; + if solid_runtime.is_some() && !ctx.solid_client { + ctx.solid_client = true; + ctx.solid_client_recollect = true; + ctx.resolve_cache.clear(); + } + let solid_module = solid_runtime + .as_deref() + .and_then(|runtime| perry_hir::solid_jsx::lower_solid_jsx(ast_module, runtime)); let lower_result = perry_hir::lower_module_full_with_platform_globals( solid_module.as_ref().unwrap_or(ast_module), &module_name, diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 6cd0cb613b..da67f6e48d 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -89,14 +89,6 @@ fn parse_boolean_switch(value: &str) -> Option { } } -fn solid_jsx_mode(value: Option<&str>) -> Result { - match value { - Some("solid") => Ok(true), - Some("default") => Ok(false), - _ => anyhow::bail!("perry.jsx must be \"solid\" or \"default\""), - } -} - fn should_auto_grant_compile_allow( has_universal_route: bool, allow_was_explicit: bool, @@ -175,7 +167,7 @@ pub(super) fn apply_pkg_and_toml_config( if let Ok(content) = fs::read_to_string(&pkg_json_path) { if let Ok(pkg) = serde_json::from_str::(&content) { if let Some(mode) = pkg.get("perry").and_then(|perry| perry.get("jsx")) { - ctx.solid_jsx = solid_jsx_mode(mode.as_str())?; + ctx.solid_jsx = super::solid_config::JsxMode::parse(mode)?; } if let Some(aliases) = pkg .get("perry") @@ -790,7 +782,8 @@ pub(super) fn apply_pkg_and_toml_config( { if let Some(perry_tbl) = table.get("perry").and_then(|v| v.as_table()) { if let Some(mode) = perry_tbl.get("jsx") { - ctx.solid_jsx = solid_jsx_mode(mode.as_str())?; + ctx.solid_jsx = + super::solid_config::JsxMode::parse(&serde_json::to_value(mode)?)?; } if let Some(strict) = perry_tbl.get("strict").and_then(|v| v.as_bool()) { ctx.strict_eval = strict; diff --git a/crates/perry/src/commands/compile/init_order.rs b/crates/perry/src/commands/compile/init_order.rs index dfea825bd8..b79dffdd7e 100644 --- a/crates/perry/src/commands/compile/init_order.rs +++ b/crates/perry/src/commands/compile/init_order.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; use crate::OutputFormat; -use super::resolve::resolve_import_with_bunfs; +use super::resolve::resolve_import_with_context; use super::CompilationContext; /// Issue #753: reachability classification for eager vs deferred init. @@ -79,14 +79,7 @@ pub(super) fn classify_eager_modules(ctx: &mut CompilationContext, entry_path: & } } for src in reexport_sources { - if let Some((resolved_path, _)) = resolve_import_with_bunfs( - &src, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_path, _)) = resolve_import_with_context(&src, path, ctx) { if ctx.native_modules.contains_key(&resolved_path) && !eager.contains(&resolved_path) { @@ -183,14 +176,7 @@ pub(super) fn topo_sort_non_entry_modules( perry_hir::Export::Named { .. } => None, }; if let Some(src) = source { - if let Some((resolved_path, _)) = resolve_import_with_bunfs( - src, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_path, _)) = resolve_import_with_context(src, path, ctx) { if resolved_path != *entry_path && ctx.native_modules.contains_key(&resolved_path) { diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 87b1eba8b6..190bc1ae87 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -39,6 +39,8 @@ use super::CompilationContext; use super::{NativeBackend, NativeLibraryManifest}; mod native_library; +mod solid; +pub(super) use solid::resolve_import_with_context; // pub(crate): the `check --check-deps` dependency checker (commands/deps.rs) // consults both resolvers so `#` subpath imports and tsconfig-aliased // specifiers stop reporting false R003 "not found in node_modules" errors. @@ -1801,14 +1803,7 @@ pub(super) fn cached_resolve_import( if let Some(cached) = ctx.resolve_cache.get(&cache_key) { return cached.clone(); } - let result = resolve_import_with_bunfs( - import_source, - importer_path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ); + let result = resolve_import_with_context(import_source, importer_path, ctx); ctx.resolve_cache.insert(cache_key, result.clone()); result } diff --git a/crates/perry/src/commands/compile/resolve/solid.rs b/crates/perry/src/commands/compile/resolve/solid.rs new file mode 100644 index 0000000000..d10fcc6171 --- /dev/null +++ b/crates/perry/src/commands/compile/resolve/solid.rs @@ -0,0 +1,83 @@ +//! Contextual OpenTUI conditions and Solid client-build selection (#10099). + +use super::*; + +/// Every graph pass must use the same conditions, including re-export fixups +/// and initialization ordering which resolve edges again after collection. +pub(in crate::commands::compile) fn resolve_import_with_context( + source: &str, + importer: &Path, + ctx: &CompilationContext, +) -> Option<(PathBuf, ModuleKind)> { + let (path, kind) = resolve_import_with_bunfs( + source, + importer, + &ctx.project_root, + &ctx.compile_packages, + &ctx.compile_package_dirs, + ctx.bunfs_root.as_deref(), + )?; + let path = if ctx.bun_platform && parse_package_specifier(source).0 == "@opentui/solid" { + opentui_bun_entry(source, &path).unwrap_or(path) + } else { + path + }; + Some(( + if ctx.solid_client { + client_entry(&path).unwrap_or(path) + } else { + path + }, + kind, + )) +} + +fn opentui_bun_entry(source: &str, path: &Path) -> Option { + // Start from the resolved copy, preserving nested versions / symlink identity. + for dir in path.parent()?.ancestors() { + let package = dir.join("package.json"); + let Ok(content) = fs::read_to_string(package) else { + continue; + }; + let json: serde_json::Value = serde_json::from_str(&content).ok()?; + if json.get("name")?.as_str()? != "@opentui/solid" { + return None; + } + let (_, subpath) = parse_package_specifier(source); + let key = subpath + .map(|s| format!("./{s}")) + .unwrap_or_else(|| ".".into()); + let entry = resolve_exports_with_conditions( + json.get("exports")?, + &key, + &["bun", "node", "import", "default"], + )?; + return resolve_with_extensions(&dir.join(entry))? + .canonicalize() + .ok(); + } + None +} + +fn client_entry(path: &Path) -> Option { + // Mirror OpenTUI's onLoad swap, including explicit/relative imports of the + // SSR files. Never enable the browser condition for unrelated packages. + if path.file_name()? != "server.js" || path.parent()?.file_name()? != "dist" { + return None; + } + let base = path.parent()?.parent()?; + let (package, client) = if base.file_name()? == "store" { + (base.parent()?, "store.js") + } else { + (base, "solid.js") + }; + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(package.join("package.json")).ok()?).ok()?; + if json.get("name")?.as_str()? != "solid-js" { + return None; + } + path.with_file_name(client).canonicalize().ok() +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry/src/commands/compile/resolve/solid/tests.rs b/crates/perry/src/commands/compile/resolve/solid/tests.rs new file mode 100644 index 0000000000..7d28d9eaaa --- /dev/null +++ b/crates/perry/src/commands/compile/resolve/solid/tests.rs @@ -0,0 +1,89 @@ +use super::*; + +fn write(dir: &Path, name: &str, source: &str) { + let path = dir.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, source).unwrap(); +} + +fn solid_package(dir: &Path) { + write( + dir, + "package.json", + r#"{"name":"solid-js","exports":{ + ".":{"node":"./dist/server.js","default":"./dist/solid.js"}, + "./store":{"node":"./store/dist/server.js","default":"./store/dist/store.js"} + }}"#, + ); + for name in [ + "dist/server.js", + "dist/solid.js", + "store/dist/server.js", + "store/dist/store.js", + ] { + write(dir, name, "export const marker = 1;"); + } +} + +#[test] +fn client_swap_is_graph_wide_and_preserves_package_instances() { + let dir = tempfile::tempdir().unwrap(); + let mut ctx = CompilationContext::new(dir.path().to_path_buf()); + ctx.compile_packages.insert("solid-js".into()); + let top = dir.path().join("node_modules/solid-js"); + let nested = dir.path().join("node_modules/holder/node_modules/solid-js"); + solid_package(&top); + solid_package(&nested); + for (importer, package) in [ + (dir.path().join("main.tsx"), top), + (dir.path().join("node_modules/holder/index.js"), nested), + ] { + ctx.solid_client = false; + let server = resolve_import_with_context("solid-js", &importer, &ctx) + .unwrap() + .0; + assert_eq!( + server, + package.join("dist/server.js").canonicalize().unwrap() + ); + ctx.solid_client = true; + for (source, expected) in [ + ("solid-js", "dist/solid.js"), + ("solid-js/store", "store/dist/store.js"), + ] { + let (path, kind) = resolve_import_with_context(source, &importer, &ctx).unwrap(); + assert_eq!(path, package.join(expected).canonicalize().unwrap()); + assert_eq!(kind, ModuleKind::NativeCompiled); + } + } + let unrelated = dir.path().join("node_modules/unrelated"); + write(&unrelated, "package.json", r#"{"name":"unrelated"}"#); + write(&unrelated, "dist/server.js", ""); + write(&unrelated, "dist/solid.js", ""); + assert!(client_entry(&unrelated.join("dist/server.js")).is_none()); +} + +#[test] +fn opentui_uses_bun_only_when_the_bun_platform_is_selected() { + let dir = tempfile::tempdir().unwrap(); + let package = dir.path().join("node_modules/@opentui/solid"); + write( + &package, + "package.json", + r#"{"name":"@opentui/solid","exports":{ + ".":{"bun":"./index.bun.js","node":"./index.js","default":"./index.js"} + }}"#, + ); + write(&package, "index.bun.js", "export const marker = 'bun';"); + write(&package, "index.js", "export const marker = 'node';"); + let mut ctx = CompilationContext::new(dir.path().to_path_buf()); + ctx.compile_packages.insert("@opentui/solid".into()); + for (bun, expected) in [(false, "index.js"), (true, "index.bun.js")] { + ctx.bun_platform = bun; + let path = + resolve_import_with_context("@opentui/solid", &dir.path().join("main.tsx"), &ctx) + .unwrap() + .0; + assert_eq!(path, package.join(expected).canonicalize().unwrap()); + } +} diff --git a/crates/perry/src/commands/compile/resolve/tsconfig_paths.rs b/crates/perry/src/commands/compile/resolve/tsconfig_paths.rs index 61e5096019..b64f3e3694 100644 --- a/crates/perry/src/commands/compile/resolve/tsconfig_paths.rs +++ b/crates/perry/src/commands/compile/resolve/tsconfig_paths.rs @@ -39,7 +39,7 @@ //! functions (the higher-level `cached_resolve_import` memoizes the final //! result on the `CompilationContext`). -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; @@ -65,11 +65,13 @@ struct TsConfig { paths: Option, /// `baseUrl` resolved to an absolute directory, if set. base_url: Option, + jsx_import_source: Option, + inputs: BTreeSet, } impl TsConfig { fn is_empty(&self) -> bool { - self.paths.is_none() && self.base_url.is_none() + self.paths.is_none() && self.base_url.is_none() && self.jsx_import_source.is_none() } } @@ -101,6 +103,22 @@ pub(crate) fn resolve_tsconfig_paths(import_source: &str, importer_path: &Path) resolve_with_config(import_source, &config) } +/// Read JSX configuration afresh for each compilation (including watch builds). +/// A dependency without its own tsconfig must not inherit the application's JSX +/// dialect across a node_modules boundary. Reuse JSONC and extends resolution. +pub(crate) fn jsx_config(importer_path: &Path) -> (Option, BTreeSet) { + let config = importer_path.parent().and_then(|dir| { + dir.ancestors() + .take_while(|dir| dir.file_name().is_none_or(|name| name != "node_modules")) + .map(|dir| dir.join("tsconfig.json")) + .find(|path| path.is_file()) + .and_then(|path| build_merged_config(&path, &mut Vec::new())) + }); + config + .map(|c| (c.jsx_import_source, c.inputs)) + .unwrap_or_default() +} + /// Resolve a specifier against an already-merged config (split out for unit /// testing the matching semantics without touching the cache/`extends` /// machinery). @@ -249,6 +267,10 @@ fn build_merged_config(tsconfig_path: &Path, seen: &mut Vec) -> Option< }; let compiler_options = json.get("compilerOptions"); + merged.inputs.insert(tsconfig_path.to_path_buf()); + if let Some(source) = compiler_options.and_then(|c| c.get("jsxImportSource")) { + merged.jsx_import_source = source.as_str().map(str::to_owned); + } // baseUrl declared here resolves relative to THIS config's dir. if let Some(base_url) = compiler_options @@ -305,7 +327,10 @@ fn resolve_extends(extends_ref: &str, config_dir: &Path) -> Option { return Some(direct); } // Append `.json` if missing. - if direct.extension().is_none() { + if direct + .extension() + .is_none_or(|extension| extension != "json") + { let with_json = config_dir.join(format!("{}.json", extends_ref)); if with_json.is_file() { return Some(with_json); @@ -338,7 +363,10 @@ fn resolve_extends_package(extends_ref: &str, config_dir: &Path) -> Option `.json`. - if pkg_path.extension().is_none() { + if pkg_path + .extension() + .is_none_or(|extension| extension != "json") + { let with_json = node_modules.join(format!("{}.json", extends_ref)); if with_json.is_file() { return Some(with_json); @@ -506,6 +534,7 @@ mod tests { paths, }), base_url: None, + ..Default::default() } } @@ -615,6 +644,7 @@ mod tests { let cfg = TsConfig { paths: None, base_url: Some(tmp.join("src")), + ..Default::default() }; let resolved = resolve_with_config("lib/x", &cfg).expect("baseUrl resolution"); assert!(resolved.ends_with("x.ts")); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index ae00b3fb64..f57c5a8f96 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -837,6 +837,7 @@ pub fn run_with_parse_cache( // loading lifted into compile/host_config.rs::apply_pkg_and_toml_config. let (i18n_config, i18n_translations) = apply_pkg_and_toml_config(&args, &project_root, &mut ctx, format)?; + ctx.solid_client = ctx.solid_jsx.runtime_for(&args.input)?.is_some(); // #1680 (Phase 2 of #1677): run host-declared build-time codegen steps // (e.g. `ajv/standalone`, `prisma generate`) before module collection so @@ -1156,14 +1157,9 @@ pub fn run_with_parse_cache( _ => None, }; if let Some((source, re_export_names)) = source_str { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, enum_name), members) in &exported_enums { if src_path == &source_path_str { @@ -1286,14 +1282,9 @@ pub fn run_with_parse_cache( let Some((source, names)) = re_export else { continue; }; - let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) else { + let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + else { continue; }; let source_path_str = resolved_source.to_string_lossy().to_string(); @@ -1852,14 +1843,9 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); if let Some(source_exports) = all_module_exports.get(&source_path_str) { let current_exports = all_module_exports.get(&path_str); @@ -1907,14 +1893,9 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); if let Some(source_exports) = all_module_exports.get(&source_path_str) { if let Some(origin) = source_exports.get(imported) { @@ -1961,14 +1942,9 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - &import.source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(&import.source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); if let Some(source_exports) = @@ -2057,14 +2033,9 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), ¶m_count) in &exported_func_param_counts { @@ -2086,14 +2057,9 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), ¶m_count) in &exported_func_param_counts { @@ -2123,14 +2089,9 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - &import.source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(&import.source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); let key_src = (source_path_str, imported_name); @@ -2177,14 +2138,9 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), return_type) in &exported_func_return_types { @@ -2210,14 +2166,9 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, func_name), return_type) in &exported_func_return_types { @@ -2250,14 +2201,9 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - &import.source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(&import.source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); let key_src = (source_path_str, imported_name); @@ -2306,14 +2252,9 @@ pub fn run_with_parse_cache( for export in &hir_module.exports { match export { perry_hir::Export::ExportAll { source } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, class_name), class) in &exported_classes { if src_path == &source_path_str { @@ -2330,14 +2271,9 @@ pub fn run_with_parse_cache( imported, exported, } => { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); for ((src_path, class_name), class) in &exported_classes { if src_path == &source_path_str && class_name == imported { @@ -2362,14 +2298,9 @@ pub fn run_with_parse_cache( _ => (false, String::new()), }; if matches { - if let Some((resolved_source, _)) = resolve_import_with_bunfs( - &import.source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_source, _)) = + resolve_import_with_context(&import.source, path, &ctx) + { let source_path_str = resolved_source.to_string_lossy().to_string(); let key_src = (source_path_str, imported_name); @@ -2671,14 +2602,9 @@ pub fn run_with_parse_cache( perry_hir::Export::ReExport { source, .. } | perry_hir::Export::ExportAll { source } | perry_hir::Export::NamespaceReExport { source, .. } => { - if let Some((resolved_path, _)) = resolve_import_with_bunfs( - source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_path, _)) = + resolve_import_with_context(source, path, &ctx) + { if let Some(name) = path_to_module_name.get(&resolved_path) { *source = name.clone(); } @@ -2699,14 +2625,9 @@ pub fn run_with_parse_cache( if import.is_native { continue; } - if let Some((resolved_path, _)) = resolve_import_with_bunfs( - &import.source, - path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), - ) { + if let Some((resolved_path, _)) = + resolve_import_with_context(&import.source, path, &ctx) + { if let Some(name) = path_to_module_name.get(&resolved_path) { import.source = name.clone(); } @@ -3344,13 +3265,10 @@ pub fn run_with_parse_cache( perry_hir::Export::Named { .. } => None, }; if let Some(src) = src { - if let Some((resolved_path, _)) = resolve_import_with_bunfs( + if let Some((resolved_path, _)) = resolve_import_with_context( &src, path, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), + &ctx, ) { if let Some(src_mod) = ctx.native_modules.get(&resolved_path) { push_dep(&mut deps, &mut seen, sanitize_name(&src_mod.name)); @@ -4191,13 +4109,10 @@ pub fn run_with_parse_cache( let perry_hir::Export::ExportAll { source } = e else { return None; }; - let (target_path, _) = resolve_import_with_bunfs( + let (target_path, _) = resolve_import_with_context( source, std::path::Path::new(&ns_scan_path), - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), + &ctx, )?; let target = target_path.to_string_lossy().to_string(); all_module_exports @@ -4209,13 +4124,10 @@ pub fn run_with_parse_cache( let Some((hop_src, hop_imported)) = named_hop.or_else(export_all_hop) else { break; }; - let Some((hop_path, _)) = resolve_import_with_bunfs( + let Some((hop_path, _)) = resolve_import_with_context( &hop_src, std::path::Path::new(&ns_scan_path), - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), + &ctx, ) else { break; }; @@ -4271,13 +4183,10 @@ pub fn run_with_parse_cache( break; } let importer = std::path::Path::new(&ns_scan_path); - let Some((ns_target, _)) = resolve_import_with_bunfs( + let Some((ns_target, _)) = resolve_import_with_context( ns_src, importer, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ctx.bunfs_root.as_deref(), + &ctx, ) else { break; }; diff --git a/crates/perry/src/commands/compile/solid_config.rs b/crates/perry/src/commands/compile/solid_config.rs new file mode 100644 index 0000000000..620e86f320 --- /dev/null +++ b/crates/perry/src/commands/compile/solid_config.rs @@ -0,0 +1,68 @@ +//! Solid universal renderer selection, shared by module collection and resolution. + +use std::path::Path; + +use anyhow::{bail, Result}; + +#[derive(Clone, Debug, Default)] +pub(super) enum JsxMode { + #[default] + Auto, + Default, + Solid(String), +} + +impl JsxMode { + pub(super) fn parse(value: &serde_json::Value) -> Result { + match value.as_str() { + Some("solid") => return Ok(Self::Solid("perry-solid".into())), + Some("default") => return Ok(Self::Default), + _ => {} + } + if let Some(runtime) = value.get("runtime").and_then(|v| v.as_str()) { + if !runtime.trim().is_empty() && runtime == runtime.trim() { + return Ok(Self::Solid(runtime.to_owned())); + } + } + bail!("perry.jsx must be \"solid\", \"default\", or {{ \"runtime\": \"module-name\" }}") + } + + pub(super) fn runtime_for(&self, source: &Path) -> Result> { + match self { + Self::Default => return Ok(None), + Self::Solid(runtime) => return Ok(Some(runtime.clone())), + Self::Auto => {} + } + // A monorepo can mix JSX dialects. Honor the nearest package's explicit + // choice; dependencies do not inherit a host package across node_modules. + if let Some(dir) = source.parent() { + for dir in dir.ancestors() { + if dir.file_name().is_some_and(|name| name == "node_modules") { + break; + } + let package = dir.join("package.json"); + if package.is_file() { + let json = std::fs::read_to_string(package) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()); + if let Some(value) = json + .as_ref() + .and_then(|json| json.get("perry")) + .and_then(|p| p.get("jsx")) + { + return Self::parse(value)?.runtime_for(source); + } + break; + } + } + } + let (source, _) = super::resolve::tsconfig_paths::jsx_config(source); + // jsxImportSource also names React/Preact and Solid's DOM renderer. + // Only known universal hosts opt in automatically; other universal + // renderers can be selected explicitly with perry.jsx.runtime. + Ok(source.filter(|source| matches!(source.as_str(), "@opentui/solid" | "perry-solid"))) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry/src/commands/compile/solid_config/tests.rs b/crates/perry/src/commands/compile/solid_config/tests.rs new file mode 100644 index 0000000000..3eb76afe4d --- /dev/null +++ b/crates/perry/src/commands/compile/solid_config/tests.rs @@ -0,0 +1,87 @@ +use super::*; + +fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +#[test] +fn jsx_runtime_follows_nearest_jsonc_config_and_extends_without_crossing_dependencies() { + let dir = tempfile::tempdir().unwrap(); + write( + &dir.path().join("tsconfig.base.json"), + r#"{"compilerOptions":{"jsxImportSource":"@opentui/solid"}}"#, + ); + write( + &dir.path().join("tsconfig.json"), + "{ // inherited JSX\n\"extends\": \"./tsconfig.base\", }", + ); + let entry = dir.path().join("src/main.tsx"); + assert_eq!( + JsxMode::Auto.runtime_for(&entry).unwrap().as_deref(), + Some("@opentui/solid") + ); + let (_, inputs) = crate::commands::compile::resolve::tsconfig_paths::jsx_config(&entry); + assert!(inputs.contains(&dir.path().join("tsconfig.base.json"))); + write( + &dir.path().join("src/react/tsconfig.json"), + r#"{"compilerOptions":{"jsxImportSource":"react"}}"#, + ); + assert!(JsxMode::Auto + .runtime_for(&dir.path().join("src/react/main.tsx")) + .unwrap() + .is_none()); + assert!(JsxMode::Auto + .runtime_for(&dir.path().join("node_modules/widget/main.tsx")) + .unwrap() + .is_none()); + // A watch/rebuild must reread the file, not reuse a process-global config. + write( + &dir.path().join("tsconfig.base.json"), + r#"{"compilerOptions":{"jsxImportSource":"react"}}"#, + ); + assert!(JsxMode::Auto.runtime_for(&entry).unwrap().is_none()); +} + +#[test] +fn explicit_runtime_and_default_override_automatic_detection() { + let dir = tempfile::tempdir().unwrap(); + let entry = dir.path().join("main.tsx"); + write( + &dir.path().join("tsconfig.json"), + r#"{"compilerOptions":{"jsxImportSource":"@opentui/solid"}}"#, + ); + for (value, expected) in [ + (serde_json::json!("solid"), Some("perry-solid")), + (serde_json::json!("default"), None), + ( + serde_json::json!({"runtime":"custom-renderer"}), + Some("custom-renderer"), + ), + ] { + assert_eq!( + JsxMode::parse(&value) + .unwrap() + .runtime_for(&entry) + .unwrap() + .as_deref(), + expected + ); + write( + &dir.path().join("package.json"), + &serde_json::json!({"perry":{"jsx": value}}).to_string(), + ); + assert_eq!( + JsxMode::Auto.runtime_for(&entry).unwrap().as_deref(), + expected + ); + } + for invalid in [ + serde_json::json!({"runtime":""}), + serde_json::json!({"runtime":true}), + serde_json::json!(true), + serde_json::json!("soldi"), + ] { + assert!(JsxMode::parse(&invalid).is_err()); + } +} diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 5c82754a6f..2bba63262c 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -694,8 +694,12 @@ pub struct CompilationContext { pub native_addon_paths: BTreeMap, /// Package aliases: maps npm package name → replacement package name (from perry.packageAliases) pub package_aliases: HashMap, - /// Opt-in Solid universal JSX expansion; ordinary JSX remains the default. - pub solid_jsx: bool, + /// Host override or per-module tsconfig/package selection of universal JSX. + pub solid_jsx: super::solid_config::JsxMode, + /// The whole graph shares Solid's client instance, including dependency code. + pub solid_client: bool, + /// A nested JSX package discovered after resolution started needs a new walk. + pub solid_client_recollect: bool, /// Packages to compile natively instead of routing to V8 (from perry.compilePackages) pub compile_packages: HashSet, /// Node native-addon packages omitted from wildcard/automatic whole-package @@ -1230,7 +1234,9 @@ impl CompilationContext { native_addons: BTreeMap::new(), native_addon_paths: BTreeMap::new(), package_aliases: HashMap::new(), - solid_jsx: false, + solid_jsx: Default::default(), + solid_client: false, + solid_client_recollect: false, compile_packages: HashSet::new(), auto_skipped_node_addon_packages: HashSet::new(), aot_discovered_modules: HashSet::new(), diff --git a/crates/perry/tests/solid_jsx_config.rs b/crates/perry/tests/solid_jsx_config.rs index 1f8a37cb24..0f9db2303e 100644 --- a/crates/perry/tests/solid_jsx_config.rs +++ b/crates/perry/tests/solid_jsx_config.rs @@ -13,7 +13,9 @@ fn fixture() -> tempfile::TempDir { std::fs::write( directory.path().join("host.ts"), "export function createElement(name: string) { return { name }; }\n\ - export function spread(node: any, props: any) { node.props = props; }\n", + export function spread(node: any, props: any) { node.props = props; }\n\ + export function createTextNode(text: string) { return { text }; }\n\ + export function insertNode(node: any, child: any) { node.child = child; }\n", ) .expect("universal host"); directory @@ -24,7 +26,9 @@ fn package(directory: &Path, mode: serde_json::Value) { directory.join("package.json"), serde_json::json!({ "type": "module", - "perry": { "jsx": mode, "packageAliases": { "perry-solid": "./host.ts" } } + "perry": { "jsx": mode, "packageAliases": { + "perry-solid": "./host.ts", "@opentui/solid": "./host.ts" + } } }) .to_string(), ) @@ -177,3 +181,130 @@ fn a_fragment_of_literals_does_not_import_an_unused_renderer() { ); assert!(String::from_utf8_lossy(&result.stdout).contains("1 native, 0 JavaScript")); } + +#[test] +fn runtime_object_selects_the_named_universal_host() { + let directory = fixture(); + package( + directory.path(), + serde_json::json!({"runtime":"@opentui/solid"}), + ); + assert_mode(directory.path(), "explicit-runtime", true); + std::fs::write( + directory.path().join("perry.toml"), + "[perry]\njsx = { runtime = '@opentui/solid' }\n", + ) + .unwrap(); + package(directory.path(), "default".into()); + assert_mode(directory.path(), "toml-runtime", true); +} + +#[test] +fn jsx_import_source_and_inherited_config_changes_reach_codegen() { + let directory = fixture(); + std::fs::write( + directory.path().join("package.json"), + serde_json::json!({ + "perry": { "packageAliases": {"@opentui/solid":"./host.ts"} } + }) + .to_string(), + ) + .unwrap(); + std::fs::write( + directory.path().join("tsconfig.json"), + "{ // JSONC\n\"extends\": \"./base.json\", }", + ) + .unwrap(); + std::fs::write( + directory.path().join("base.json"), + r#"{"compilerOptions":{"jsxImportSource":"@opentui/solid"}}"#, + ) + .unwrap(); + assert_mode(directory.path(), "auto-solid", true); + std::fs::write( + directory.path().join("base.json"), + r#"{"compilerOptions":{"jsxImportSource":"react"}}"#, + ) + .unwrap(); + assert_mode(directory.path(), "auto-react.o", false); + std::fs::write( + directory.path().join("base.json"), + r#"{"compilerOptions":{"jsxImportSource":"@opentui/solid"}}"#, + ) + .unwrap(); + assert_mode(directory.path(), "auto-solid-again", true); + package(directory.path(), "default".into()); + assert_mode(directory.path(), "explicit-default.o", false); +} + +#[test] +fn nested_jsx_package_recollects_earlier_solid_imports_with_the_client_build() { + let directory = fixture(); + let root = directory.path(); + std::fs::write(root.join("package.json"), r#"{"private":true}"#).unwrap(); + let runtime = root.join("node_modules/@opentui/solid"); + std::fs::create_dir_all(&runtime).unwrap(); + std::fs::write( + runtime.join("package.json"), + r#"{"name":"@opentui/solid","main":"index.ts"}"#, + ) + .unwrap(); + std::fs::copy(root.join("host.ts"), runtime.join("index.ts")).unwrap(); + std::fs::create_dir_all(root.join("ui")).unwrap(); + std::fs::write( + root.join("ui/tsconfig.json"), + r#"{"compilerOptions":{"jsxImportSource":"@opentui/solid"}}"#, + ) + .unwrap(); + std::fs::write( + root.join("ui/view.tsx"), + "export const view = Hello;", + ) + .unwrap(); + std::fs::write(root.join("main.tsx"), "import { signal } from 'solid-js'; import { view } from './ui/view'; console.log(signal(), view);").unwrap(); + let solid = root.join("node_modules/solid-js"); + std::fs::create_dir_all(solid.join("dist")).unwrap(); + std::fs::write( + solid.join("package.json"), + r#"{"name":"solid-js","exports":{"node":"./dist/server.js","default":"./dist/solid.js"}}"#, + ) + .unwrap(); + std::fs::write( + solid.join("dist/server.js"), + "export function signal() { return 'server'; }", + ) + .unwrap(); + std::fs::write( + solid.join("dist/solid.js"), + "export function signal() { return 'client'; }", + ) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_perry")) + .current_dir(root) + .args([ + "compile", + "main.tsx", + "--no-link", + "--print-hir", + "-o", + "nested/output.o", + ]) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let hir = String::from_utf8_lossy(&output.stdout).replace('\\', "/"); + assert!( + hir.contains("dist/solid.js"), + "client build must reach HIR: {hir}" + ); + assert!( + !hir.contains("dist/server.js"), + "earlier SSR module must be removed: {hir}" + ); + assert!(!hir.contains("jsx-runtime"), "no React fallback: {hir}"); +} diff --git a/docs/src/getting-started/project-config.md b/docs/src/getting-started/project-config.md index 5d464b359c..79a1663118 100644 --- a/docs/src/getting-started/project-config.md +++ b/docs/src/getting-started/project-config.md @@ -37,6 +37,30 @@ The generated `package.json` carries the npm-interop layer. The `perry.compilePa The `perry` field in `package.json` controls compiler behavior: +#### `jsx` + +Perry compiles Solid universal JSX ahead of time when the source file's nearest +`tsconfig.json` sets `compilerOptions.jsxImportSource` to `@opentui/solid` or +`perry-solid`. JSONC comments and inherited settings through `extends` are +supported. Each package can have its own JSX configuration. + +To explicitly select another Solid universal renderer, set its module name: + +```json +{ "perry": { "jsx": { "runtime": "@opentui/solid" } } } +``` + +The existing `"jsx": "solid"` selects `perry-solid`; `"jsx": "default"` +disables automatic Solid detection. A host setting overrides per-file +detection. The equivalent TOML setting is `[perry]` with +`jsx = { runtime = "@opentui/solid" }`; TOML overrides the host's package setting. +React/Preact import sources keep their existing JSX behavior. + +In a Solid universal graph, `solid-js` and `solid-js/store` use their reactive +client builds, including imports from dependencies. With `--platform bun`, +`@opentui/solid` selects `index.bun.js`; otherwise it selects `index.js`. +JSX compilation itself does not require Babel or a runtime loader plugin. + #### `compilePackages` List npm packages to compile natively instead of routing through the JavaScript runtime: diff --git a/packages/perry-solid/README.md b/packages/perry-solid/README.md index 7221198481..b357c2e150 100644 --- a/packages/perry-solid/README.md +++ b/packages/perry-solid/README.md @@ -68,11 +68,13 @@ perry examples/counter.ts -o counter ### JSX -Set `"jsx": "solid"` inside your application's `perry` configuration alongside -the Solid client aliases above. The equivalent TOML setting is `[perry]` with -`jsx = "solid"`. An omitted setting, or `"default"`, keeps Perry's existing JSX -behavior. Perry performs the transform in the compiler; Babel is only used as -an independent test oracle for this package. +Set `"jsx": "solid"` inside your application's `perry` configuration, or set +`"jsxImportSource": "perry-solid"` in the nearest `tsconfig.json` for automatic +detection. The equivalent TOML setting is `[perry]` with `jsx = "solid"`. +Perry selects Solid's client builds for this graph; the explicit client aliases +above remain supported. `"jsx": "default"` disables automatic Solid detection. +Perry performs the transform in the compiler; Babel is only used as an +independent test oracle for this package. ```tsx import { createSignal } from "solid-js"; diff --git a/tests/release/packages/opentui-solid/.gitignore b/tests/release/packages/opentui-solid/.gitignore new file mode 100644 index 0000000000..7fd04df928 --- /dev/null +++ b/tests/release/packages/opentui-solid/.gitignore @@ -0,0 +1,2 @@ +/work/ +/node_modules/ diff --git a/tests/release/packages/opentui-solid/README.md b/tests/release/packages/opentui-solid/README.md new file mode 100644 index 0000000000..5f49968291 --- /dev/null +++ b/tests/release/packages/opentui-solid/README.md @@ -0,0 +1,24 @@ +Compile-time Solid universal JSX regression for #10099, using OpenTUI 0.4.5 +(the OpenCode v1.18.30 renderer) and its declared Solid 1.9.12 peer version. + +`fixture.sh` compares a native executable with the official OpenTUI transform. +The headless host uses the real `solid-js/universal` renderer and exercises +signal/store updates, static identifiers, property effects, mixed child ranges, +component getters, refs, directives, spread precedence, fragments, control flow, +and disposal. `tsconfig.json` selects the dialect automatically; a fixture alias +routes only the renderer host to `host.ts`. + +For differential expansion tests and actual OpenTUI character frames: + +```sh +cargo build -p perry-hir --example solid_jsx --profile perry-dev +python tests/release/packages/opentui-solid/compare.py \ + target/perry-dev/examples/solid_jsx \ + --perry target/perry-dev/perry \ + --opencode /path/to/opencode-v1.18.30 +``` + +The optional OpenCode argument checks every TSX file in both source trees with +the real transform and Perry's ordinary HIR lowering. It checks for residual +JSX runtime calls; it does not execute OpenCode's full worker/native-addon graph. +That application's first native TUI frame also depends on #10100, #10103, #10105. diff --git a/tests/release/packages/opentui-solid/compare.py b/tests/release/packages/opentui-solid/compare.py new file mode 100644 index 0000000000..241791e2f4 --- /dev/null +++ b/tests/release/packages/opentui-solid/compare.py @@ -0,0 +1,85 @@ +"""Compare Perry's expansion with OpenTUI's real Babel transform under Bun. + +Build `cargo build -p perry-hir --example solid_jsx --profile perry-dev`, then: +python compare.py /path/to/solid_jsx [--perry /path/to/perry] [--opencode /path/to/v1.18.30] +The optional corpus check expands and lowers every TSX source to HIR and runs +the upstream transform on the same inputs. It does not execute the full app. +""" + +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("expander", type=Path) +parser.add_argument("--perry", type=Path) +parser.add_argument("--opencode", type=Path) +args = parser.parse_args() +fixture = Path(__file__).resolve().parent +work = fixture / "work" +work.mkdir(exist_ok=True) +for name in ["package.json", "tsconfig.json", "host.ts", "main.tsx", "frames.tsx", "oracle.mjs"]: + shutil.copy2(fixture / name, work / name) + + +def run(command, timeout=180, env=None): + result = subprocess.run([str(arg) for arg in command], cwd=work, env=env, + capture_output=True, text=True, timeout=timeout) + if result.returncode: + raise RuntimeError(f"{command}\n{result.stdout}\n{result.stderr}") + return result.stdout + + +run(["bun", "install", "--ignore-scripts"]) +expander = args.expander.resolve() +expected = (fixture / "expected.txt").read_text() +for name, runtime in [("main", "./host.ts"), ("frames", "@opentui/solid")]: + run(["bun", "oracle.mjs", f"{name}.tsx"]) + run([expander, f"{name}.tsx", runtime, f"{name}.expanded.ts"]) + oracle = run(["bun", "--conditions=browser", f"{name}.oracle.ts"]) + actual = run(["bun", "--conditions=browser", f"{name}.expanded.ts"]) + if actual != oracle: + raise AssertionError(f"{name}: oracle={oracle!r}\nPerry={actual!r}") + if name == "main" and actual != expected: + raise AssertionError(f"unexpected fixture output: {actual!r}") + print(f"PASS {name}: Perry expansion and OpenTUI transform agree", flush=True) + +if args.perry: + binary = work / ("main.exe" if os.name == "nt" else "main-bin") + compiled = run([args.perry.resolve(), "compile", "--no-cache", "main.tsx", "-o", binary], timeout=600) + if "5 native, 0 JavaScript" not in compiled: + raise AssertionError(f"unexpected live graph (entry, host, Solid core/store/universal): {compiled}") + actual = run([binary]) + if actual != expected: + raise AssertionError(f"native output={actual!r}, expected={expected!r}") + print("PASS native: universal renderer and reactive core/store", flush=True) + +if args.opencode: + root = args.opencode.resolve() + revision = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip() + if revision != "3104c1428ec91f809e5ab86631300de41eb6952e": + raise AssertionError(f"expected OpenCode v1.18.30 (3104c14), got {revision}") + files = sorted([*root.glob("packages/tui/src/**/*.tsx"), *root.glob("packages/opencode/src/**/*.tsx")]) + if len(files) < 107: + raise AssertionError(f"incomplete OpenCode corpus: {len(files)} TSX files") + # One Bun process runs the real transform for the entire corpus. + (work / "corpus.json").write_text(json.dumps([str(path) for path in files])) + (work / "corpus.mjs").write_text(''' +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +const require = createRequire(import.meta.url); +const plugin = require.resolve("@opentui/solid/bun-plugin"); +const { transformSolidSource } = await import(pathToFileURL(join(dirname(plugin), "solid-transform.js"))); +for (const filename of JSON.parse(readFileSync("corpus.json", "utf8"))) { + await transformSolidSource(readFileSync(filename, "utf8"), { filename, moduleName: "@opentui/solid" }); +} +''') + run(["bun", "corpus.mjs"], timeout=600) + for source in files: + run([expander, source, "@opentui/solid", "corpus.expanded.ts"]) + print(f"PASS corpus: {len(files)} TSX files pass OpenTUI transform and Perry expansion/HIR", flush=True) diff --git a/tests/release/packages/opentui-solid/expected.txt b/tests/release/packages/opentui-solid/expected.txt new file mode 100644 index 0000000000..8b5945ae69 --- /dev/null +++ b/tests/release/packages/opentui-solid/expected.txt @@ -0,0 +1 @@ +PASS universal JSX: effects, children, components, refs, directives, spreads, fragments, control flow, client core/store diff --git a/tests/release/packages/opentui-solid/fixture.sh b/tests/release/packages/opentui-solid/fixture.sh new file mode 100755 index 0000000000..73714ae878 --- /dev/null +++ b/tests/release/packages/opentui-solid/fixture.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${1:-}" == "--__did-skip-marker" ]] && exit 1 +cd "$(dirname "$0")" +source ../_fixture_lib.sh +fixture_dir="$PWD" +mkdir -p work +cp package.json package-lock.json tsconfig.json host.ts main.tsx oracle.mjs work/ +cd work +npm ci --ignore-scripts --no-audit --no-fund > install.log 2>&1 +fixture_setup opentui-solid +node oracle.mjs +node --conditions=browser main.oracle.ts > oracle-out.txt +diff -u "$fixture_dir/expected.txt" oracle-out.txt +PERRY_DISABLE_BUILD_CACHE=1 fixture_compile_run_diff opentui-solid main.tsx "$fixture_dir/expected.txt" +grep -q '5 native, 0 JavaScript' perry-compile.log diff --git a/tests/release/packages/opentui-solid/frames.tsx b/tests/release/packages/opentui-solid/frames.tsx new file mode 100644 index 0000000000..96c6676ab8 --- /dev/null +++ b/tests/release/packages/opentui-solid/frames.tsx @@ -0,0 +1,25 @@ +import { createSignal, For } from "solid-js"; +import { testRender, effect } from "@opentui/solid"; + +const [count, setCount] = createSignal(0); +const [props, setProps] = createSignal({ title: "first", padding: 0 }); +const [rows, setRows] = createSignal(["A", "B"]); +let label: any; +let seen = 0; +function watch(node: any, value: () => number) { effect(() => { seen = value(); }); } +function Label(props: any) { return {props.value}; } +const view = await testRender(() => + 0 ? "green" : "white"}>Count {count()} + , { width: 32, height: 8 }); +await view.renderOnce(); +const frames = [view.captureCharFrame()]; +setCount(1); +setRows(["B", "A"]); +setProps({ title: "second", padding: 2 }); +await view.renderOnce(); +frames.push(view.captureCharFrame()); +if (!label || seen !== 1) throw new Error("refs/directive did not update"); +view.renderer.destroy(); +console.log(JSON.stringify(frames)); diff --git a/tests/release/packages/opentui-solid/host.ts b/tests/release/packages/opentui-solid/host.ts new file mode 100644 index 0000000000..4ef1390ce4 --- /dev/null +++ b/tests/release/packages/opentui-solid/host.ts @@ -0,0 +1,40 @@ +import { createRenderer } from "solid-js/universal"; + +export type Node = { tag: string; text: string; props: any; parent: Node | null; children: Node[] }; +export function makeNode(tag: string, text = ""): Node { + return { tag, text, props: {}, parent: null, children: [] }; +} +const renderer = createRenderer({ + createElement: tag => makeNode(tag), + createTextNode: text => makeNode("#text", text), + isTextNode: node => node.tag === "#text", + replaceText: (node, text) => { node.text = text; }, + setProperty: (node, key, value) => { node.props[key] = value; }, + insertNode(parent, node, marker) { + if (node === marker) return; + if (node.parent) { + const old = node.parent.children.indexOf(node); + node.parent.children.splice(old, 1); + } + const index = marker ? parent.children.indexOf(marker) : parent.children.length; + parent.children.splice(index, 0, node); + node.parent = parent; + }, + removeNode(parent, node) { + parent.children.splice(parent.children.indexOf(node), 1); + node.parent = null; + }, + getParentNode: node => node.parent, + getFirstChild: node => node.children[0], + getNextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1], +}); +export const { createElement, createTextNode, insertNode, insert, setProp, spread, + createComponent, effect, memo, mergeProps, use, render } = renderer; + +export function content(node: Node): string { + if (node.tag === "#text") return node.text; + return node.children.map(content).join(""); +} +export function expect(actual: any, expected: any, label: string) { + if (actual !== expected) throw new Error(label + ": " + String(actual) + " != " + String(expected)); +} diff --git a/tests/release/packages/opentui-solid/main.tsx b/tests/release/packages/opentui-solid/main.tsx new file mode 100644 index 0000000000..85cade003e --- /dev/null +++ b/tests/release/packages/opentui-solid/main.tsx @@ -0,0 +1,93 @@ +import { createSignal, For, Show, Switch, Match, onCleanup } from "solid-js"; +import { createStore } from "solid-js/store"; +import { render, effect, makeNode, content, expect, type Node } from "./host.ts"; + +const [count, setCount] = createSignal(0); +const [handler, setHandler] = createSignal(() => setCount(count() + 1)); +const [visible, setVisible] = createSignal(1); +const [items, setItems] = createSignal(["A", "B"]); +const [spreadProps, setSpreadProps] = createSignal({ width: 10, title: "first" }); +const [store, setStore] = createStore({ title: "store 0" }); +let plain = "initial"; +let native!: Node; +let branch!: Node; +let spreadNode!: Node; +let component!: Node; +let forwarded!: Node; +let typedRef!: Node; +const member: { current?: Node } = {}; +let callback!: Node; +let refs = 0; +let componentRuns = 0; +let directives = 0; +let directiveValue = -1; +let cleanups = 0; +const capture = (node: Node) => { count(); callback = node; refs++; }; +function focus(node: Node, accessor: () => number) { + directives++; + effect(() => { directiveValue = accessor(); }); + onCleanup(() => cleanups++); +} +function Panel(props: any) { + componentRuns++; + return {props.children}; +} +function Forward(props: any) { return forwarded; } +const root = makeNode("root"); +const dispose = render(() => + + before {count()} middle {count() + 1} after + + child {count()} + + member + callback + + typed + + {visible() && branch} + {item => {item}} + 0} fallback={zero}>positive + switch 0 0}>switch 1 + <>{"fragment "}{count()} +, root); +expect(content(native), "before 0 middle 1 after", "initial mixed children"); +expect(native.props.width, 1, "initial dynamic prop"); +expect(content(member.current!), "member", "member ref"); +expect(content(callback), "callback", "callback ref"); +expect(content(forwarded), "forwarded", "component ref"); +expect(content(typedRef), "typed", "non-null ref"); +const firstBranch = branch; +plain = "changed"; +native.props.onPress(); +setStore("title", "store 1"); +expect(content(native), "before 1 middle 2 after", "updated insertion ranges"); +expect(native.props.width, 2, "updated dynamic prop"); +expect(native.props.plain, "initial", "plain identifier stays static"); +expect(component.props.plain, "initial", "component identifier stays static"); +expect(component.props.label, "store 1", "store uses client build"); +expect(content(component), "child 1", "component children getter"); +expect(componentRuns, 1, "component does not rerun"); +expect(directives, 1, "directive runs once"); +expect(directiveValue, 1, "directive accessor tracks"); +expect(refs, 1, "ref callback runs untracked"); +setSpreadProps({ width: 20, title: "second" }); +expect(spreadNode.props.width, 20, "spread update"); +expect(spreadNode.props.title, "last 1", "attribute after spread wins"); +setVisible(2); +expect(branch, firstBranch, "truthy branch identity"); +setVisible(0); +expect(firstBranch.parent, null, "branch removed"); +setVisible(1); +expect(branch === firstBranch, false, "branch recreated"); +setItems(["B", "A"]); +expect(content(root).includes("BApositiveswitch 1fragment 1"), true, "control flow and fragment update"); +setHandler(() => () => setCount(count() + 10)); +native.props.onPress(); +expect(count(), 11, "event handler prop updates"); +const before = content(native); +dispose(); +setCount(9); +expect(content(native), before, "disposed effects stay stopped"); +expect(cleanups, 1, "directive cleanup"); +console.log("PASS universal JSX: effects, children, components, refs, directives, spreads, fragments, control flow, client core/store"); diff --git a/tests/release/packages/opentui-solid/oracle.mjs b/tests/release/packages/opentui-solid/oracle.mjs new file mode 100644 index 0000000000..200adb3274 --- /dev/null +++ b/tests/release/packages/opentui-solid/oracle.mjs @@ -0,0 +1,10 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +const require = createRequire(import.meta.url); +const plugin = require.resolve("@opentui/solid/bun-plugin"); +const { transformSolidSource } = await import(pathToFileURL(join(dirname(plugin), "solid-transform.js"))); +const filename = process.argv[2] || "main.tsx"; +const moduleName = filename === "main.tsx" ? "./host.ts" : "@opentui/solid"; +writeFileSync(filename.replace(".tsx", ".oracle.ts"), await transformSolidSource(readFileSync(filename, "utf8"), { filename, moduleName })); diff --git a/tests/release/packages/opentui-solid/package-lock.json b/tests/release/packages/opentui-solid/package-lock.json new file mode 100644 index 0000000000..077ba34ded --- /dev/null +++ b/tests/release/packages/opentui-solid/package-lock.json @@ -0,0 +1,1380 @@ +{ + "name": "perry-release-fixture-opentui-solid", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-release-fixture-opentui-solid", + "dependencies": { + "@opentui/solid": "0.4.5", + "solid-js": "1.9.12" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@opentui/core": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.4.5.tgz", + "integrity": "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==", + "license": "MIT", + "dependencies": { + "bun-ffi-structs": "0.2.4", + "diff": "9.0.0", + "marked": "17.0.1", + "string-width": "7.2.0", + "strip-ansi": "7.1.2" + }, + "optionalDependencies": { + "@opentui/core-darwin-arm64": "0.4.5", + "@opentui/core-darwin-x64": "0.4.5", + "@opentui/core-linux-arm64": "0.4.5", + "@opentui/core-linux-arm64-musl": "0.4.5", + "@opentui/core-linux-x64": "0.4.5", + "@opentui/core-linux-x64-musl": "0.4.5", + "@opentui/core-win32-arm64": "0.4.5", + "@opentui/core-win32-x64": "0.4.5" + }, + "peerDependencies": { + "web-tree-sitter": "0.25.10" + } + }, + "node_modules/@opentui/core-darwin-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.4.5.tgz", + "integrity": "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-darwin-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.4.5.tgz", + "integrity": "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-linux-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.4.5.tgz", + "integrity": "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-arm64-musl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.4.5.tgz", + "integrity": "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.4.5.tgz", + "integrity": "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64-musl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.4.5.tgz", + "integrity": "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-win32-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.4.5.tgz", + "integrity": "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/core-win32-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.4.5.tgz", + "integrity": "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/solid": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/solid/-/solid-0.4.5.tgz", + "integrity": "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==", + "license": "MIT", + "dependencies": { + "@babel/core": "7.28.0", + "@babel/preset-typescript": "7.27.1", + "@opentui/core": "0.4.5", + "babel-plugin-module-resolver": "5.0.2", + "babel-preset-solid": "1.9.12", + "entities": "7.0.1", + "s-js": "^0.4.9" + }, + "peerDependencies": { + "solid-js": "1.9.12" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions": { + "version": "0.40.10", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.10.tgz", + "integrity": "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.20.12" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-plugin-module-resolver": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.2.tgz", + "integrity": "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==", + "license": "MIT", + "dependencies": { + "find-babel-config": "^2.1.1", + "glob": "^9.3.3", + "pkg-up": "^3.1.0", + "reselect": "^4.1.7", + "resolve": "^1.22.8" + } + }, + "node_modules/babel-preset-solid": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz", + "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", + "license": "MIT", + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.40.6" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "solid-js": "^1.9.12" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bun-ffi-structs": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.2.4.tgz", + "integrity": "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==", + "license": "MIT", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/find-babel-config": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz", + "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.3" + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/s-js": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", + "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/solid-js": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz", + "integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.0", + "seroval-plugins": "~1.5.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/tests/release/packages/opentui-solid/package.json b/tests/release/packages/opentui-solid/package.json new file mode 100644 index 0000000000..f6c6d57767 --- /dev/null +++ b/tests/release/packages/opentui-solid/package.json @@ -0,0 +1,12 @@ +{ + "name": "perry-release-fixture-opentui-solid", + "private": true, + "type": "module", + "dependencies": { + "@opentui/solid": "0.4.5", + "solid-js": "1.9.12" + }, + "perry": { + "packageAliases": { "@opentui/solid": "./host.ts" } + } +} diff --git a/tests/release/packages/opentui-solid/tsconfig.json b/tests/release/packages/opentui-solid/tsconfig.json new file mode 100644 index 0000000000..828f63b98a --- /dev/null +++ b/tests/release/packages/opentui-solid/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "jsx": "preserve", "jsxImportSource": "@opentui/solid" } +} From b692ea6b736a7424104a9ae951797ae2299c373f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 12 Sep 2026 16:02:56 +0200 Subject: [PATCH 34/36] docs: key Solid JSX changeset to PR 10135 (cherry picked from commit f58c1fd3379b7ef070cc791e071d3fc6ffcf4f92) --- .../{10099-solid-universal.md => 10135-solid-universal.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10099-solid-universal.md => 10135-solid-universal.md} (100%) diff --git a/changelog.d/10099-solid-universal.md b/changelog.d/10135-solid-universal.md similarity index 100% rename from changelog.d/10099-solid-universal.md rename to changelog.d/10135-solid-universal.md From c1bd1d5f87a6392c6228883563124937e0376d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 17:18:52 +0200 Subject: [PATCH 35/36] fix(train167): repair two cross-PR breaks the batch exposed Neither PR is wrong on its own; both breaks only exist once the twelve land together, which is what the train is for. - `module_require/dynamic_import_tests.rs` (#10131) called `js_module_dynamic_import_fallback` and `js_module_dynamic_import_deferred` at their old arity. #10128 gave both an `options` parameter for import attributes (`import(path, { with: { type: "toml" } })`), so the three test call sites now pass `undefined()` for it. Test-only; no behaviour change. - `compile/solid_config.rs` (#10135) declares `JsxMode` as `pub(super)` while `CompilationContext::solid_jsx` exposes it as a `pub` field on a `pub(crate)` struct, which is a private-interface violation and fails the product scope under `-D warnings`: type `JsxMode` is more private than the item `compile::types::CompilationContext::solid_jsx` Widened to `pub(crate)` to match its own exposure. --- .../src/module_require/dynamic_import_tests.rs | 9 +++++++-- crates/perry/src/commands/compile/solid_config.rs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/module_require/dynamic_import_tests.rs b/crates/perry-runtime/src/module_require/dynamic_import_tests.rs index 49f5b3a5a9..51dd3091b5 100644 --- a/crates/perry-runtime/src/module_require/dynamic_import_tests.rs +++ b/crates/perry-runtime/src/module_require/dynamic_import_tests.rs @@ -52,7 +52,10 @@ fn unresolved_imports_name_the_module_and_native_build_remedy() { "file:///config/plugins/tui.tsx", "@example/custom-provider", ] { - let message = rejection_message(js_module_dynamic_import_fallback(string_value(specifier))); + let message = rejection_message(js_module_dynamic_import_fallback( + string_value(specifier), + undefined(), + )); assert_native_guidance(&message, specifier); } } @@ -66,6 +69,7 @@ fn deferred_import_keeps_the_site_and_adds_the_runtime_specifier() { ); let message = rejection_message(js_module_dynamic_import_deferred( specifier.get_nanbox_f64(), + undefined(), note, )); assert_native_guidance(&message, "some-npm-plugin"); @@ -78,7 +82,8 @@ fn deferred_builtin_imports_still_resolve() { let scope = crate::gc::RuntimeHandleScope::new(); let specifier = scope.root_nanbox_f64(string_value(specifier)); let note = string_value("deferred import at src/plugin/loader.ts:139"); - let value = js_module_dynamic_import_deferred(specifier.get_nanbox_f64(), note); + let value = + js_module_dynamic_import_deferred(specifier.get_nanbox_f64(), undefined(), note); assert_ne!(crate::promise::js_value_is_promise(value), 0); let promise = crate::value::js_nanbox_get_pointer(value) as *mut Promise; assert_eq!(js_promise_state(promise), 1, "builtin import must resolve"); diff --git a/crates/perry/src/commands/compile/solid_config.rs b/crates/perry/src/commands/compile/solid_config.rs index 620e86f320..f3ebbaa8ff 100644 --- a/crates/perry/src/commands/compile/solid_config.rs +++ b/crates/perry/src/commands/compile/solid_config.rs @@ -5,7 +5,7 @@ use std::path::Path; use anyhow::{bail, Result}; #[derive(Clone, Debug, Default)] -pub(super) enum JsxMode { +pub(crate) enum JsxMode { #[default] Auto, Default, From 67878e34b906c8cb1c986db7ab8a2673ef598bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 17:45:18 +0200 Subject: [PATCH 36/36] test(runtime): assert the deferred-import note, not the whole message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10128 and #10131 disagree about `js_module_dynamic_import_deferred`'s message when a call-site note is present, and both land here: - #10131 always emits the actionable native-build explanation and appends the note, which is the whole point of that PR — callers that surface `error.message` get something they can act on. - #10128's `runtime_data_import_loaders_and_rejections` asserted the message equals the note exactly, which was true before #10131. They call the same function, so the contradiction is real rather than a merge artifact. Resolved in #10131's favour, because #10128's assertion sits under "Existing runtime files without a supported data attribute remain deferred" — with no data attribute the import falls through to the ordinary JavaScript module deferral, which is exactly the case #10131's explanation is written for. The message there is strictly more useful than it was. What that test is actually asserting is that the deferral keeps its call-site note, so it now checks the note is carried instead of pinning the full text. Flagged on #10128 so its author can say if they want the data path to keep a message of its own; that is a product call about user-facing error text, not something a merge should silently decide. --- .../src/module_require/data_import/tests.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/module_require/data_import/tests.rs b/crates/perry-runtime/src/module_require/data_import/tests.rs index a5fd853da1..a7bb6eac47 100644 --- a/crates/perry-runtime/src/module_require/data_import/tests.rs +++ b/crates/perry-runtime/src/module_require/data_import/tests.rs @@ -134,9 +134,16 @@ fn runtime_data_import_loaders_and_rejections() { note.get_nanbox_f64(), ); let error = scope.root_nanbox_f64(settled(promise, 2)); - assert_eq!( - string_bytes(property(error.get_nanbox_f64(), b"message").unwrap()).as_deref(), - Some("deferred test site") + // No supported data attribute, so this falls through to the ordinary + // JavaScript-module deferral — which #10131 now prefixes with the + // actionable native-build explanation. What this test is actually + // asserting is that the deferral keeps its call-site note, so check the + // note is carried rather than pinning the whole message text. + let deferred_message = + string_bytes(property(error.get_nanbox_f64(), b"message").unwrap()).unwrap(); + assert!( + deferred_message.ends_with("deferred test site"), + "the deferred site note must survive: {deferred_message}" ); std::fs::remove_file(path).unwrap();