From 6c248e8fde6c611f4fba3e9e6d9c88558ca02a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 15:50:46 +0200 Subject: [PATCH 01/32] test(parity): let harness classify stalled gap fixtures (#10720) --- .../test_gap_9493_child_stdin_backpressure.ts | 26 +++++-------------- .../test_gap_9592_child_timeout_threads.ts | 13 ++++------ 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/test-files/test_gap_9493_child_stdin_backpressure.ts b/test-files/test_gap_9493_child_stdin_backpressure.ts index 0098613d7d..48fbd9101d 100644 --- a/test-files/test_gap_9493_child_stdin_backpressure.ts +++ b/test-files/test_gap_9493_child_stdin_backpressure.ts @@ -20,7 +20,6 @@ import { spawn } from "node:child_process"; const ROLE_ENV = "PERRY_9493_STDIN_ROLE"; const FILE_ENV = "PERRY_9493_STDIN_FILE"; -const WATCHDOG_MS = 8000; const BIG = 4 * 1024 * 1024; const role = process.env[ROLE_ENV] ?? ""; @@ -85,11 +84,9 @@ if (role === "stdin-small-exit") { const childArgs = [...process.execArgv, ...process.argv.slice(1)]; const waitForMarker = (marker: string) => - new Promise((resolve) => { - const deadline = Date.now() + WATCHDOG_MS; + new Promise((resolve) => { const poll = () => { - if (fs.existsSync(marker)) return resolve(true); - if (Date.now() > deadline) return resolve(false); + if (fs.existsSync(marker)) return resolve(); setTimeout(poll, 20); }; poll(); @@ -103,12 +100,12 @@ if (role === "stdin-small-exit") { env: { ...process.env, [ROLE_ENV]: name, [FILE_ENV]: file }, stdio: ["ignore", "inherit", "inherit"], }); - let settled = false; - const report = async (code: number | null | string) => { + const report = async (code: number | null) => { if (silent) { - const landed = (await waitForMarker(file + ".done")) && fs.existsSync(file) - ? fs.statSync(file).size - : -1; + // If the marker never appears, let the parity harness report a + // timeout instead of printing a clock-dependent `no-marker` result. + await waitForMarker(file + ".done"); + const landed = fs.existsSync(file) ? fs.statSync(file).size : -1; const total = name === "stdin-small-exit" ? 6 : BIG; const kind = landed < 0 ? "no-marker" : landed === 0 ? "none" : landed >= total ? "full" : "partial"; console.log(name + " exit=" + code + " landed=" + kind); @@ -117,16 +114,7 @@ if (role === "stdin-small-exit") { } resolve(); }; - const watchdog = setTimeout(() => { - if (settled) return; - settled = true; - child.kill("SIGKILL"); - void report("WATCHDOG"); - }, WATCHDOG_MS); child.on("exit", (code) => { - if (settled) return; - settled = true; - clearTimeout(watchdog); void report(code); }); }); diff --git a/test-files/test_gap_9592_child_timeout_threads.ts b/test-files/test_gap_9592_child_timeout_threads.ts index 3ef205cf43..7c7c4e694a 100644 --- a/test-files/test_gap_9592_child_timeout_threads.ts +++ b/test-files/test_gap_9592_child_timeout_threads.ts @@ -34,15 +34,12 @@ await Promise.all(quickChildren); if (process.platform !== "linux") { console.log("timeout threads released: skipped (no /proc task census)"); } else { - let timeoutThreadsReleased = false; - const releaseDeadline = Date.now() + 1_000; - while (!timeoutThreadsReleased && Date.now() < releaseDeadline) { - timeoutThreadsReleased = threadCount() <= baseline + 5; - if (!timeoutThreadsReleased) { - await new Promise((resolve) => setTimeout(resolve, 20)); - } + // Let the parity harness report a timeout if the threads never drain. + // A fixture-local deadline would print `false` as a parity mismatch. + while (threadCount() > baseline + 5) { + await new Promise((resolve) => setTimeout(resolve, 20)); } - console.log("timeout threads released:", timeoutThreadsReleased); + console.log("timeout threads released: true"); } const started = Date.now(); From ee2956b6af2b55db6dd3bf672572299515f6778a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 15:51:07 +0200 Subject: [PATCH 02/32] docs: add PR 10985 changelog fragment --- changelog.d/10985-parity-fixture-timeouts.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/10985-parity-fixture-timeouts.md diff --git a/changelog.d/10985-parity-fixture-timeouts.md b/changelog.d/10985-parity-fixture-timeouts.md new file mode 100644 index 0000000000..e7c59e06c0 --- /dev/null +++ b/changelog.d/10985-parity-fixture-timeouts.md @@ -0,0 +1 @@ +Report stalled thread-release and stdin-backpressure gap fixtures as timeouts instead of parity mismatches caused by local deadlines. From c29bd7da5a16a09d860d8220ea43841b970bad90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 15:57:17 +0200 Subject: [PATCH 03/32] fix(buffer): align both write entry points on encoding tags (#10645) --- crates/perry-runtime/src/buffer/copy_write.rs | 68 ++++++++++++++----- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/crates/perry-runtime/src/buffer/copy_write.rs b/crates/perry-runtime/src/buffer/copy_write.rs index 20315d64b8..54f2f0957a 100644 --- a/crates/perry-runtime/src/buffer/copy_write.rs +++ b/crates/perry-runtime/src/buffer/copy_write.rs @@ -65,11 +65,7 @@ pub extern "C" fn js_buffer_write( let str_data = (str_ptr as *const u8).add(std::mem::size_of::()); let str_bytes = std::slice::from_raw_parts(str_data, str_len); - let bytes_to_write = match encoding { - 1 => decode_hex(str_bytes), - 2 => decode_base64(str_bytes), - _ => str_bytes.to_vec(), - }; + let bytes_to_write = super::from::buffer_string_bytes_for_encoding(str_bytes, encoding); let available = (buf_len - offset) as usize; let write_len = bytes_to_write.len().min(available); @@ -102,17 +98,9 @@ pub extern "C" fn js_buffer_write_len( let str_data = (str_ptr as *const u8).add(std::mem::size_of::()); let str_bytes = std::slice::from_raw_parts(str_data, str_len); - let bytes_to_write = match encoding { - 1 => decode_hex(str_bytes), - 2 | 3 => decode_base64(str_bytes), - // #10426: encoding tag 6 (utf16le/ucs2) fell through to the - // default (raw UTF-8 bytes) arm — dormant in the pre-existing - // `buf.write(str, offset, 'utf16le')` path and would have made - // the new `ucs2Write` method equally wrong. `from::utf16le_string_bytes` - // is the same UTF-16LE encoder `Buffer.from(str, 'utf16le')` uses. - 6 => super::from::utf16le_string_bytes(str_bytes), - _ => str_bytes.to_vec(), - }; + // Share the encoding table with the no-length entry point, including + // utf16le/ucs2, base64url, and latin1/ascii. + let bytes_to_write = super::from::buffer_string_bytes_for_encoding(str_bytes, encoding); let available = (buf_len - offset) as usize; let cap = max_len.max(0) as usize; @@ -124,3 +112,51 @@ pub extern "C" fn js_buffer_write_len( write_len as i32 } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_write_entry_points_decode_every_encoding_tag() { + let cases: &[(&str, &str, i32, &[u8])] = &[ + ("utf8", "Aé", 0, b"A\xc3\xa9"), + ("hex", "41e9", 1, &[0x41, 0xe9]), + ("base64", "SGk=", 2, b"Hi"), + ("base64url", "_w==", 3, &[0xff]), + ("latin1", "Aé", 4, &[0x41, 0xe9]), + ("ascii", "Aé", 5, &[0x41, 0xe9]), + ("utf16le/ucs2", "Aé", 6, &[0x41, 0x00, 0xe9, 0x00]), + ]; + + for &(name, input, encoding, expected) in cases { + for with_length in [false, true] { + let buffer = js_buffer_alloc(16, 0x7f); + let string = + crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + let written = if with_length { + js_buffer_write_len(buffer, string, 1, 15, encoding) + } else { + js_buffer_write(buffer, string, 1, encoding) + }; + assert_eq!( + written as usize, + expected.len(), + "{name}, with_length={with_length}" + ); + let bytes = unsafe { std::slice::from_raw_parts(buffer_data(buffer), 16) }; + assert_eq!(bytes[0], 0x7f, "{name}, with_length={with_length}"); + assert_eq!( + &bytes[1..1 + expected.len()], + expected, + "{name}, with_length={with_length}" + ); + assert_eq!( + bytes[1 + expected.len()], + 0x7f, + "{name}, with_length={with_length}" + ); + } + } + } +} From 471b73f227965059b4ea6f3cd218bdffc4b2ca06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 15:57:54 +0200 Subject: [PATCH 04/32] docs: add PR 10987 changelog fragment --- changelog.d/10987-buffer-write-encodings.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/10987-buffer-write-encodings.md diff --git a/changelog.d/10987-buffer-write-encodings.md b/changelog.d/10987-buffer-write-encodings.md new file mode 100644 index 0000000000..502f62211f --- /dev/null +++ b/changelog.d/10987-buffer-write-encodings.md @@ -0,0 +1 @@ +Make both Buffer write entry points honor utf16le/ucs2, base64url, and latin1/ascii encoding tags through the same conversion path. From f26f4cc91475afc24dbeacdf064e88a200d57844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 16:04:30 +0200 Subject: [PATCH 05/32] fix(string): index WTF-8 positions without unchecked UTF-8 (#10725) --- crates/perry-runtime/src/string/compare.rs | 31 +++++++++++++++++-- crates/perry-runtime/src/string/mod.rs | 7 ++--- crates/perry-runtime/src/string/slice_ops.rs | 2 +- ...est_gap_10725_wtf8_starts_ends_position.ts | 20 ++++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 test-files/test_gap_10725_wtf8_starts_ends_position.ts diff --git a/crates/perry-runtime/src/string/compare.rs b/crates/perry-runtime/src/string/compare.rs index 8c347bf008..5188e9f2ea 100644 --- a/crates/perry-runtime/src/string/compare.rs +++ b/crates/perry-runtime/src/string/compare.rs @@ -566,13 +566,14 @@ pub extern "C" fn js_string_starts_with_at( let prefix_blen = unsafe { (*prefix).byte_len } as usize; + let blen = unsafe { (*s).byte_len } as usize; let byte_start = if is_ascii_string(s) { pos } else { - utf16_offset_to_byte_offset(string_as_str(s), pos) + let bytes = unsafe { std::slice::from_raw_parts(string_data(s), blen) }; + utf16_offset_to_byte_offset(bytes, pos) }; - let blen = unsafe { (*s).byte_len } as usize; if byte_start + prefix_blen > blen { return 0; } @@ -609,7 +610,9 @@ pub extern "C" fn js_string_ends_with_at( let byte_end = if is_ascii_string(s) { end_u16 } else { - utf16_offset_to_byte_offset(string_as_str(s), end_u16) + let blen = unsafe { (*s).byte_len } as usize; + let bytes = unsafe { std::slice::from_raw_parts(string_data(s), blen) }; + utf16_offset_to_byte_offset(bytes, end_u16) }; let suffix_blen = unsafe { (*suffix).byte_len } as usize; @@ -632,6 +635,28 @@ pub extern "C" fn js_string_ends_with_at( 1 } +#[cfg(test)] +mod position_wtf8_tests { + use super::*; + + #[test] + fn starts_and_ends_positions_cross_lone_surrogates() { + let bytes = b"\xed\xa0\x80abc\xed\xb0\x80xyz"; + let source = js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32); + let abc = js_string_from_bytes(b"abc".as_ptr(), 3); + let xyz = js_string_from_bytes(b"xyz".as_ptr(), 3); + + assert_eq!(js_string_starts_with_at(source, abc, 1), 1); + assert_eq!(js_string_starts_with_at(source, abc, 0), 0); + assert_eq!(js_string_starts_with_at(source, xyz, 5), 1); + assert_eq!(js_string_starts_with_at(source, xyz, 4), 0); + assert_eq!(js_string_ends_with_at(source, abc, 4), 1); + assert_eq!(js_string_ends_with_at(source, abc, 3), 0); + assert_eq!(js_string_ends_with_at(source, xyz, 8), 1); + assert_eq!(js_string_ends_with_at(source, xyz, 7), 0); + } +} + /// String.prototype.normalize(form) — Unicode normalization. /// /// `form_value` is the raw NaN-boxed argument (or NaN-boxed `undefined` diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 6762d72506..77fbfcd055 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -614,19 +614,18 @@ pub(crate) fn wtf8_step(bytes: &[u8], i: usize) -> (usize, usize, u32) { ) } -/// Convert a UTF-16 code unit index to a UTF-8 byte offset. -/// Returns `s.len()` if `utf16_idx` is past the end. +/// Convert a UTF-16 code unit index to a WTF-8 byte offset. +/// Returns `bytes.len()` if `utf16_idx` is past the end. /// /// Bounds-driven byte walk (#6085): the previous `s.chars()` loop decoded /// through the UTF-8-validity assumption, which over-reads an exact-sized /// payload ending in a truncated multi-byte lead. `wtf8_step` reads only /// bounds-checked bytes; valid input maps identically. #[inline] -pub(crate) fn utf16_offset_to_byte_offset(s: &str, utf16_idx: usize) -> usize { +pub(crate) fn utf16_offset_to_byte_offset(bytes: &[u8], utf16_idx: usize) -> usize { if utf16_idx == 0 { return 0; } - let bytes = s.as_bytes(); let mut byte_off = 0usize; let mut u16_count = 0usize; while byte_off < bytes.len() { diff --git a/crates/perry-runtime/src/string/slice_ops.rs b/crates/perry-runtime/src/string/slice_ops.rs index feb740cde8..d254ddcdd5 100644 --- a/crates/perry-runtime/src/string/slice_ops.rs +++ b/crates/perry-runtime/src/string/slice_ops.rs @@ -539,7 +539,7 @@ pub extern "C" fn js_string_index_of_from( } else { from_index as usize }; - let byte_start = utf16_offset_to_byte_offset(h, u16_start); + let byte_start = utf16_offset_to_byte_offset(h.as_bytes(), u16_start); if byte_start > h.len() { if n.is_empty() { return (*haystack).utf16_len as i32; diff --git a/test-files/test_gap_10725_wtf8_starts_ends_position.ts b/test-files/test_gap_10725_wtf8_starts_ends_position.ts new file mode 100644 index 0000000000..2366aa5d73 --- /dev/null +++ b/test-files/test_gap_10725_wtf8_starts_ends_position.ts @@ -0,0 +1,20 @@ +// #10725: position-based startsWith/endsWith must count WTF-8 lone +// surrogates as one UTF-16 code unit without treating their bytes as UTF-8. +// Build both surrogates at runtime so the receiver reaches the runtime path. +const high = String.fromCharCode(0xd800); +const low = String.fromCharCode(0xdc00); +const source = high + "abc" + low + "xyz"; + +console.log("starts after high", source.startsWith("abc", 1)); +console.log("starts before high", source.startsWith("abc", 0)); +console.log("starts at high", source.startsWith(high, 0)); +console.log("starts at low", source.startsWith(low, 4)); +console.log("starts after low", source.startsWith("xyz", 5)); +console.log("starts before low", source.startsWith("xyz", 4)); + +console.log("ends after high", source.endsWith(high, 1)); +console.log("ends before high", source.endsWith(high, 0)); +console.log("ends before low", source.endsWith("abc", 4)); +console.log("ends inside abc", source.endsWith("abc", 3)); +console.log("ends after low", source.endsWith(low, 5)); +console.log("ends after xyz", source.endsWith("xyz", 8)); From 66abfeb32a6c524ce40ab140e484b9a50aadadb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 16:04:52 +0200 Subject: [PATCH 06/32] docs: add PR 10989 changelog fragment --- changelog.d/10989-wtf8-position-search.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/10989-wtf8-position-search.md diff --git a/changelog.d/10989-wtf8-position-search.md b/changelog.d/10989-wtf8-position-search.md new file mode 100644 index 0000000000..d65edf1ce7 --- /dev/null +++ b/changelog.d/10989-wtf8-position-search.md @@ -0,0 +1 @@ +Read WTF-8 string positions directly as bytes for `startsWith` and `endsWith`, avoiding an unchecked UTF-8 borrow when a string contains a lone surrogate. From a9bf8dc7c6b153cbf32193435cfb967182410773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 16:24:59 +0200 Subject: [PATCH 07/32] fix(size-report): count linker-folded symbol addresses once --- .../perry/src/commands/compile/size_report.rs | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/perry/src/commands/compile/size_report.rs b/crates/perry/src/commands/compile/size_report.rs index 00a2cda757..066684d770 100644 --- a/crates/perry/src/commands/compile/size_report.rs +++ b/crates/perry/src/commands/compile/size_report.rs @@ -9,7 +9,9 @@ //! segment is the attributed crate. ELF carries a real per-symbol size; //! Mach-O does not, so its sizes come from sorting symbols by address //! within a section and taking the distance to the next one (an upper -//! bound — it also counts any anonymous padding between them). +//! bound — it also counts any anonymous padding between them). Symbols +//! sharing a section and address are linker-folded aliases; their bytes are +//! charged once to a deterministic representative. //! //! Deliberately does not attempt cargo-bsize's DWARF/LTO-provenance analysis //! (type layout, source-line attribution, assembly instruction patterns): @@ -207,6 +209,21 @@ struct RawSymbol<'a> { exact: bool, } +/// Linker identical-code folding leaves multiple symbol names at one address. +/// Pick one stable representative per section/address so every aggregation +/// charges the emitted bytes once. Prefer a real symbol size over an inferred +/// one, then the largest size if the symbol table disagrees. +fn unique_addresses(raw: &mut Vec>) { + raw.sort_by(|a, b| { + (a.section, a.address) + .cmp(&(b.section, b.address)) + .then_with(|| b.exact.cmp(&a.exact)) + .then_with(|| b.size.cmp(&a.size)) + .then_with(|| a.name.cmp(b.name)) + }); + raw.dedup_by(|a, b| (a.section, a.address) == (b.section, b.address)); +} + fn build_report(exe_path: &Path) -> anyhow::Result { let data = fs::read(exe_path)?; let file = object::File::parse(&*data)?; @@ -243,7 +260,10 @@ fn build_report(exe_path: &Path) -> anyhow::Result { }; bucket.push((section_index.0 as u64, symbol.address(), name)); if symbol.size() != 0 { - sizes.insert((section_index.0 as u64, symbol.address()), symbol.size()); + sizes + .entry((section_index.0 as u64, symbol.address())) + .and_modify(|size| *size = (*size).max(symbol.size())) + .or_insert(symbol.size()); } } @@ -281,6 +301,7 @@ fn build_report(exe_path: &Path) -> anyhow::Result { }); } } + unique_addresses(&mut raw); let code_section_indices: std::collections::HashSet = file .sections() @@ -557,7 +578,7 @@ fn build_suggestions( out.push(Suggestion { kind: "generic-monomorphization", summary: format!( - "`{}::{}` is monomorphized {} times, {} total — consider a dynamic-dispatch (`dyn Trait`) or type-erased path if the call sites don't need static dispatch", + "`{}::{}` has {} distinct linked instantiations, {} total — consider a dynamic-dispatch (`dyn Trait`) or type-erased path if the call sites don't need static dispatch", family.crate_name, family.family, family.instantiations, @@ -985,6 +1006,46 @@ fn human_bytes(bytes: u64) -> String { mod tests { use super::*; + #[test] + fn folded_symbol_aliases_are_charged_once() { + let mut symbols = vec![ + RawSymbol { + section: 1, + address: 100, + name: "z_alias", + size: 16, + exact: true, + }, + RawSymbol { + section: 1, + address: 100, + name: "a_alias", + size: 16, + exact: true, + }, + RawSymbol { + section: 1, + address: 200, + name: "other_body", + size: 16, + exact: true, + }, + RawSymbol { + section: 2, + address: 100, + name: "other_section", + size: 8, + exact: true, + }, + ]; + unique_addresses(&mut symbols); + assert_eq!(symbols.len(), 3); + assert_eq!(symbols.iter().map(|sym| sym.size).sum::(), 40); + assert_eq!(symbols[0].name, "a_alias"); + assert_eq!(symbols[1].name, "other_body"); + assert_eq!(symbols[2].name, "other_section"); + } + #[test] fn std_internal_backtrace_copy_detected_from_real_cfi_symbols() { // Real demangled symbols pulled from a compiled binary's second From f6eb6b4809b770db0cee35d73fc9fab008331bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 16:25:31 +0200 Subject: [PATCH 08/32] docs: note folded symbol size report fix --- changelog.d/10994-report-size-icf-aliases.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10994-report-size-icf-aliases.md diff --git a/changelog.d/10994-report-size-icf-aliases.md b/changelog.d/10994-report-size-icf-aliases.md new file mode 100644 index 0000000000..ac2f6ce602 --- /dev/null +++ b/changelog.d/10994-report-size-icf-aliases.md @@ -0,0 +1,3 @@ +### Fixed + +- `perry compile --report-size` counts linker-folded symbol aliases once per address, preventing inflated crate totals and duplicate-body savings suggestions ([#10994](https://github.com/PerryTS/perry/pull/10994)). From 6bbb34e5f39acea9df639de62f601df3e1af0da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 17:30:57 +0200 Subject: [PATCH 09/32] gate: the asserted-global rule could not see `static mut` Review finding on #10947. `_STATIC` required the identifier immediately after `static`, so a `static mut` declaration never matched at all; and had it matched, `_SHARED_TY` would have excluded it anyway, because a `static mut` is usually a plain integer or array rather than an Atomic or a lock. That is a hole exactly where the hazard is worst. An `AtomicU64` read under contention gives a wrong count; racing on a `static mut` is undefined behaviour. The one shape the rule most needed to catch was the one shape it structurally could not. Fixed by capturing an optional `mut` and treating its presence as sufficient on its own -- a `static mut` is shared mutable state by definition, so it does not have to argue its way past a type filter. Latent today, and stated as such rather than claimed as a catch: the tree's only two `static mut` declarations (ohos_napi.rs) are asserted by no test, so the baseline stays at 62 entries and this commit changes no current verdict. It closes the gap before one arrives. The line anchor keeps `&'static mut` references out: the three in test helpers begin with `let` or `fn`, not `static`. Checked against all five real occurrences in the tree plus four constructed near-misses. Self-test gains a `static mut` case that fails without the fix. --- scripts/global_sink_isolation.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/global_sink_isolation.py b/scripts/global_sink_isolation.py index ca39b798d8..6a7e817e77 100644 --- a/scripts/global_sink_isolation.py +++ b/scripts/global_sink_isolation.py @@ -672,7 +672,18 @@ def self_test() -> int: # An `assert*!(...)` invocation, body included (non-greedy to the first `);` # at the end of a line, which is how this codebase formats them). _ASSERT_CALL = re.compile(r"\bassert(?:_eq|_ne)?!\s*\(.*?\)\s*;", re.S) -_STATIC = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?static\s+([A-Z][A-Z0-9_]*)\s*:\s*(.+?)\s*=") +# `mut` is optional and CAPTURED. A `static mut` is shared mutable state by +# definition -- racing on one is UB, not merely a wrong count -- so it is the +# most dangerous shape this rule claims to cover, and it escaped twice over: +# the identifier is not immediately after `static`, so it never matched, and +# its type is usually a plain integer, so `_SHARED_TY` would have excluded it +# even if it had. A gate with a hole exactly where the hazard is worst. +# +# Latent today: the tree's only two (`ohos_napi.rs`) are asserted by no test, +# so the baseline is unchanged. This closes the gap before one arrives. +_STATIC = re.compile( + r"^\s*(?:pub(?:\([^)]*\))?\s+)?static\s+(mut\s+)?([A-Z][A-Z0-9_]*)\s*:\s*(.+?)\s*=" +) def _test_region(text: str) -> str: @@ -711,8 +722,8 @@ def asserted_globals(sources) -> set[str]: in_safe = False continue m = _STATIC.match(line) - if m and _SHARED_TY.search(m.group(2)): - found.append((m.group(1), m.group(2))) + if m and (m.group(1) or _SHARED_TY.search(m.group(3))): + found.append((m.group(2), m.group(3))) if found: bare[path] = found @@ -904,6 +915,18 @@ def asserted_self_test() -> int: print("self-test FAILED: a static no test ASSERTS on was reported", file=sys.stderr) return 1 + static_mut = [( + "e.rs", + "static mut HITS: u64 = 0;\n" + "#[cfg(test)]\nmod tests {\n" + " #[test]\n fn t() { unsafe { assert_eq!(HITS, 1); } }\n}\n", + )] + if asserted_globals(static_mut) != {"e.rs::HITS"}: + print("self-test FAILED: an asserted `static mut` was NOT reported " + "(it matches neither the identifier position nor the shared-type " + "filter, which is the gap this case exists for)", file=sys.stderr) + return 1 + production_only = [( "d.rs", "static HITS: AtomicU64 = AtomicU64::new(0);\n" @@ -913,7 +936,8 @@ def asserted_self_test() -> int: print("self-test FAILED: a non-test assertion was reported", file=sys.stderr) return 1 - print("asserted-global self-test: reports the hazard and none of the three near-misses") + print("asserted-global self-test: reports the hazard (including `static " + "mut`) and none of the three near-misses") return 0 From 0f7c0e6b5e8551a30d2752dccf0600b0cfab98c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 17:54:35 +0200 Subject: [PATCH 10/32] fix(cli): diagnose missing extension archives before linking --- crates/perry-codegen/src/ext_registry.rs | 19 ++++++ .../src/commands/compile/optimized_libs.rs | 61 ++++++++++++++++++- .../commands/compile/optimized_libs/tests.rs | 25 ++++++++ .../src/commands/compile/run_pipeline.rs | 40 ++++++++++-- 4 files changed, 139 insertions(+), 6 deletions(-) diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index f9c8853d5f..32b5baa349 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -643,6 +643,25 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[ ("js_bun_build", "typescript"), ]; +/// Return the well-known binding that provides an emitted FFI symbol. The +/// compile driver's pre-link check uses the same exact and prefix registries +/// as codegen's provider routing, including the two-provider Bun server call. +pub fn well_known_owner_for_symbol(symbol: &str) -> Option<&'static str> { + if symbol == "js_bun_serve" { + return Some("http"); + } + if let Some((_, owner)) = FFI_REGISTRY.iter().find(|(name, _)| *name == symbol) { + return match owner { + OwnerKind::WellKnown(key) => Some(key), + OwnerKind::Stdlib { .. } => None, + }; + } + EXT_PREFIX_REGISTRY + .iter() + .find(|(prefix, _)| symbol.starts_with(prefix)) + .map(|(_, binding)| *binding) +} + /// Process-wide collector of provider keys observed during codegen. /// Populated by [`record_ffi_call`] from `LlBlock::call` / `call_void`. /// Drained by [`take_used_providers`] right before `build_optimized_libs`. diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index b7d2bffd1f..d2d5411424 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -11,7 +11,8 @@ //! use a hash-keyed target dir so consecutive runs with the same //! profile are no-ops after the first build. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::ffi::OsStr; use std::path::PathBuf; use super::CompilationContext; @@ -93,3 +94,61 @@ pub(crate) fn well_known_iteration_set(ctx: &CompilationContext) -> BTreeSet, + linked_definitions: &HashSet, + resolved_ext_libs: &[PathBuf], + target: Option<&str>, +) -> Vec { + let mut missing: BTreeMap = BTreeMap::new(); + for symbol in emitted_undefined { + if linked_definitions.contains(symbol) { + continue; + } + let Some(owner) = perry_codegen::ext_registry::well_known_owner_for_symbol(symbol) else { + continue; + }; + let Some(binding) = super::well_known::lookup_well_known(owner) else { + continue; + }; + let filename = super::well_known::ext_staticlib_filename( + &binding.lib, + super::rust_target_triple(target), + ); + if resolved_ext_libs + .iter() + .any(|path| path.file_name() == Some(OsStr::new(&filename))) + { + continue; + } + missing.entry(filename).or_insert(( + symbol, + &binding.krate, + binding_needs_shared_tokio(owner), + )); + } + missing + .into_iter() + .map(|(filename, (symbol, krate, shared_tokio))| { + let build = if shared_tokio { + format!( + "cargo build --release -p perry -p perry-runtime-static \ + -p perry-stdlib-static -p {krate}" + ) + } else { + format!("cargo build --release -p {krate}") + }; + format!( + "`{symbol}` needs {filename}, but that archive is not linked. \ + Build it with: {build}. Make it available through \ + PERRY_RUNTIME_DIR or PERRY_LIB_DIR if Perry is installed \ + outside the workspace." + ) + }) + .collect() +} diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index b38a7ef2da..f1aebea8e7 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -25,6 +25,31 @@ fn write_file(path: &Path, contents: &[u8]) { std::fs::write(path, contents).expect("write test file"); } +#[test] +fn missing_ext_archive_names_emitted_symbols_and_their_providers() { + let emitted = BTreeSet::from([ + "js_bun_tcp_nm_install".to_string(), + "js_ext_http_nm_install".to_string(), + ]); + let definitions = HashSet::new(); + let missing = missing_ext_archive_diagnostics(&emitted, &definitions, &[], None); + assert_eq!(missing.len(), 2); + assert!(missing.iter().any(|line| { + line.contains("js_bun_tcp_nm_install") + && line.contains("libperry_ext_net.a") + && line.contains("-p perry-stdlib-static -p perry-ext-net") + })); + assert!(missing.iter().any(|line| { + line.contains("js_ext_http_nm_install") + && line.contains("libperry_ext_http.a") + && line.contains("-p perry-stdlib-static -p perry-ext-http") + })); + + let resolved = [PathBuf::from("/archive/libperry_ext_net.a")]; + let definitions = HashSet::from(["js_ext_http_nm_install".to_string()]); + assert!(missing_ext_archive_diagnostics(&emitted, &definitions, &resolved, None).is_empty()); +} + fn minimal_auto_workspace(dir: &Path) { write_file(&dir.join("Cargo.toml"), b"[workspace]\n"); write_file(&dir.join("Cargo.lock"), b"# lock\n"); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 14119048ef..e4b86054a8 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -6252,9 +6252,10 @@ pub fn run_with_parse_cache( // Generate stubs for missing symbols from unresolved imports (npm packages etc.) { - use std::collections::HashSet; + use std::collections::{BTreeSet, HashSet}; let mut undefined_syms: HashSet = HashSet::new(); let mut defined_syms: HashSet = HashSet::new(); + let mut emitted_ext_syms: BTreeSet = BTreeSet::new(); // Prefer the auto-built runtime so the symbol-stub scan and the // final link see the same artifact (panic mode + feature set). let runtime_lib_path = optimized_libs @@ -6282,6 +6283,12 @@ pub fn run_with_parse_cache( if let Some(ref p) = wasm_host_lib_path { all_scan_paths.push(p.clone()); } + // Wrapper archives can contain more than one provider's symbols + // through static dependencies. Count their definitions before + // reporting a missing wrapper for an emitted FFI call. + let ext_scan_start = all_scan_paths.len(); + all_scan_paths.extend(optimized_libs.well_known_libs.iter().cloned()); + let ext_scan_end = all_scan_paths.len(); // Scan UI library for defined symbols so we don't generate stubs for // functions that exist in the platform UI library (e.g. screen detection FFI) if ctx.needs_ui { @@ -6339,11 +6346,13 @@ pub fn run_with_parse_cache( "nm".to_string() }; // Scan object files in parallel for symbol resolution - let scan_results: Vec<(HashSet, HashSet)> = all_scan_paths + let scan_results: Vec<(HashSet, HashSet, HashSet)> = all_scan_paths .par_iter() - .map(|scan_path| { + .enumerate() + .map(|(index, scan_path)| { let mut local_undef = HashSet::new(); let mut local_def = HashSet::new(); + let mut local_ext = HashSet::new(); if let Ok(output) = std::process::Command::new(&nm_cmd) .arg("-g") .arg(scan_path) @@ -6363,6 +6372,17 @@ pub fn run_with_parse_cache( sn }; if st == "U" { + // Wrapper-private references do not need the + // app's generated missing-symbol stubs. + if (ext_scan_start..ext_scan_end).contains(&index) { + continue; + } + if index < obj_paths.len() + && perry_codegen::ext_registry::well_known_owner_for_symbol(cn) + .is_some() + { + local_ext.insert(cn.to_string()); + } if cn.starts_with("__export_") || cn.starts_with("__wrapper_") { local_undef.insert(cn.to_string()); } else if !will_link_stdlib @@ -6391,14 +6411,24 @@ pub fn run_with_parse_cache( } } } - (local_undef, local_def) + (local_undef, local_def, local_ext) }) .collect(); // Merge parallel scan results - for (local_undef, local_def) in scan_results { + for (local_undef, local_def, local_ext) in scan_results { undefined_syms.extend(local_undef); defined_syms.extend(local_def); + emitted_ext_syms.extend(local_ext); + } + let missing_ext = optimized_libs::missing_ext_archive_diagnostics( + &emitted_ext_syms, + &defined_syms, + &optimized_libs.well_known_libs, + target.as_deref(), + ); + if !missing_ext.is_empty() { + return Err(anyhow!(missing_ext.join("\n"))); } let missing: Vec = undefined_syms.difference(&defined_syms).cloned().collect(); if !missing.is_empty() { From fbc12b556417bbd65a7c9b19e180a2b9eb03f10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 17:55:04 +0200 Subject: [PATCH 11/32] docs: add changelog entry for extension archive diagnostics --- changelog.d/11007-missing-ext-archive-diagnostic.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/11007-missing-ext-archive-diagnostic.md diff --git a/changelog.d/11007-missing-ext-archive-diagnostic.md b/changelog.d/11007-missing-ext-archive-diagnostic.md new file mode 100644 index 0000000000..bd9dfad825 --- /dev/null +++ b/changelog.d/11007-missing-ext-archive-diagnostic.md @@ -0,0 +1,6 @@ +### Fixed + +- Compiling with a missing native extension archive now reports the archive, + its Cargo package, and where to make it available before invoking the linker. + Network wrappers include a build command that keeps their Tokio copy paired + with Perry's runtime and stdlib archives. From 26835fa876663b5bf005e1e3a4053aeae056025f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:03:58 +0200 Subject: [PATCH 12/32] fix: preserve names of interpreted functions and classes --- crates/perry-runtime/src/dyn_eval/interp.rs | 6 ++ crates/perry-runtime/src/dyn_eval/tests.rs | 15 +++++ .../issue_10676_dynamic_function_names.rs | 57 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 crates/perry/tests/issue_10676_dynamic_function_names.rs diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index e4f4788485..543560f221 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -470,6 +470,9 @@ pub(crate) fn make_function_value( ctx.wasm_allowed, ); let closure_idx = root_push(closure); + let closure_ptr = crate::value::js_nanbox_get_pointer(root_get(closure_idx)) + as *mut crate::closure::ClosureHeader; + crate::object::set_bound_native_closure_name(closure_ptr, &fn_name); env::define(root_get(name_env_idx), &fn_name, root_get(closure_idx)); let closure = root_get(closure_idx); roots_truncate(name_env_idx); @@ -1199,6 +1202,9 @@ pub(crate) fn eval_class_expr(ctx: &Ctx, class_expr: &ast::ClassExpr, env_idx: u let ctor_idx = root_push(ctor_closure); if let Some(name) = &name { + let ctor_ptr = crate::value::js_nanbox_get_pointer(root_get(ctor_idx)) + as *mut crate::closure::ClosureHeader; + crate::object::set_bound_native_closure_name(ctor_ptr, name); env::define(root_get(body_env_idx), name, root_get(ctor_idx)); } diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index 0fe12e05da..9f436ab22d 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -279,6 +279,21 @@ fn named_function_expression_recursion_and_expando() { assert_eq!(as_num(calls), 1.0); } +#[test] +fn interpreted_functions_and_classes_keep_their_declared_names() { + for (source, expected) in [ + ("return function Named() {};", "Named"), + ("function Declared() {} return Declared;", "Declared"), + ("return class Widget {};", "Widget"), + ] { + let factory = dyn_fn(&[source]); + let value_idx = root_push(call(factory, &[])); + let name = bridge::get_member(root_get(value_idx), "name"); + assert_eq!(as_str(name), expected, "source: {source}"); + roots_truncate(value_idx); + } +} + #[test] fn closures_capture_interpreter_scope() { let f = dyn_fn(&[r#" diff --git a/crates/perry/tests/issue_10676_dynamic_function_names.rs b/crates/perry/tests/issue_10676_dynamic_function_names.rs new file mode 100644 index 0000000000..6847a222ec --- /dev/null +++ b/crates/perry/tests/issue_10676_dynamic_function_names.rs @@ -0,0 +1,57 @@ +//! Runtime-built Function sources keep their parsed function and class names. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn dynamic_function_and_class_names_are_visible() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +const literal: any = new Function("return function Literal() {}")(); +const functionParts = ["return function ", "Named", "() {}"]; +const dynamic: any = new Function(functionParts.join(""))(); +const classParts = ["return class ", "Widget", " {}"]; +const klass: any = new Function(classParts.join(""))(); +console.log("FUNCTION", literal.name, dynamic.name); +console.log("CLASS", klass.name); +console.log("CONSTRUCTOR", new Function("return 1").name); +"#, + ) + .expect("write fixture"); + + let compiler = PathBuf::from(env!("CARGO_BIN_EXE_perry")); + let compile = Command::new(compiler) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .output() + .expect("compile fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(binary) + .current_dir(dir.path()) + .output() + .expect("run fixture"); + assert!( + run.status.success(), + "fixture failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "FUNCTION Literal Named\nCLASS Widget\nCONSTRUCTOR anonymous\n" + ); +} From 7f02a6145a0f3e95208e66e722fa343bdd3f819e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:04:17 +0200 Subject: [PATCH 13/32] docs: add changelog for dynamic function names --- changelog.d/11008-dynamic-function-names.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11008-dynamic-function-names.md diff --git a/changelog.d/11008-dynamic-function-names.md b/changelog.d/11008-dynamic-function-names.md new file mode 100644 index 0000000000..a93aa1b3d6 --- /dev/null +++ b/changelog.d/11008-dynamic-function-names.md @@ -0,0 +1 @@ +Fixed `.name` on named functions and classes returned from runtime-generated `new Function` sources, including sources assembled from strings. From 648628de41d59b1afa13d20f019d93c0aed0b360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:57:15 +0200 Subject: [PATCH 14/32] fix: suppress deferred CJS cycle property warnings --- .../compile/cjs_wrap/extract_requires.rs | 67 +++++++++++++++++++ .../compile/cjs_wrap/issue_10760_tests.rs | 15 +++++ .../src/commands/compile/cjs_wrap/mod.rs | 2 + .../src/commands/compile/cjs_wrap/wrap.rs | 10 ++- .../issue_10178.rs | 30 +++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 crates/perry/src/commands/compile/cjs_wrap/issue_10760_tests.rs diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs index 5e7b061c15..680b54bc78 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs @@ -483,6 +483,73 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { .collect() } +/// Property reads in a function body cannot be inferred to happen at the +/// preceding `require()` call. Used by the circular-dependency warning scan: +/// warning there would run even when the function is called after the cycle +/// finishes. An immediately invoked function still runs during module init. +pub(super) fn deferred_function_sites( + masked: &str, + sites: &[usize], +) -> std::collections::HashSet { + if sites.is_empty() { + return std::collections::HashSet::new(); + } + let bytes = masked.as_bytes(); + let is_ident = |c: u8| c == b'_' || c == b'$' || c.is_ascii_alphanumeric(); + let mut open: Vec> = Vec::new(); + let mut functions: Vec<(usize, usize, bool)> = Vec::new(); + for i in 0..bytes.len() { + match bytes[i] { + b'{' => { + let mut p = i; + while p > 0 && bytes[p - 1].is_ascii_whitespace() { + p -= 1; + } + let function = if p >= 2 && &bytes[p - 2..p] == b"=>" { + true + } else if p > 0 && bytes[p - 1] == b')' { + !matches!( + matched_open_head(masked, bytes, p - 1, &is_ident).as_str(), + "if" | "for" | "while" | "switch" | "catch" | "with" + ) + } else { + false + }; + open.push(function.then_some(i)); + } + b'}' => { + if let Some(Some(start)) = open.pop() { + let mut after = i + 1; + while after < bytes.len() && bytes[after].is_ascii_whitespace() { + after += 1; + } + while after < bytes.len() && bytes[after] == b')' { + after += 1; + while after < bytes.len() && bytes[after].is_ascii_whitespace() { + after += 1; + } + } + let tail = &masked[after..]; + let immediate = tail.starts_with('(') + || tail.starts_with(".call(") + || tail.starts_with(".apply("); + functions.push((start, i, immediate)); + } + } + _ => {} + } + } + sites + .iter() + .copied() + .filter(|site| { + functions + .iter() + .any(|(start, end, immediate)| !immediate && start < site && site < end) + }) + .collect() +} + /// Is the `require(` call whose match starts at masked-source offset /// `call_start` reached only conditionally by a nearby operator or a /// braceless control-flow header, even though it has no enclosing `{ }` diff --git a/crates/perry/src/commands/compile/cjs_wrap/issue_10760_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/issue_10760_tests.rs new file mode 100644 index 0000000000..d7b9aebb31 --- /dev/null +++ b/crates/perry/src/commands/compile/cjs_wrap/issue_10760_tests.rs @@ -0,0 +1,15 @@ +#[test] +fn circular_warning_scan_distinguishes_deferred_reads_from_module_init() { + let source = "const a = require('./a');\n\ + function later() { return a.later; }\n\ + if (true) { a.now; }\n\ + (function () { a.immediate; })();\n"; + let masked = super::detect::strip_comments_and_strings(source); + let sites: Vec = ["a.later", "a.now", "a.immediate"] + .iter() + .map(|needle| masked.find(needle).unwrap()) + .collect(); + let deferred = super::extract_requires::deferred_function_sites(&masked, &sites); + assert_eq!(deferred.len(), 1); + assert!(deferred.contains(&sites[0])); +} diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index 115b8b5898..a0cad0f04c 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -47,6 +47,8 @@ mod wrap; #[cfg(test)] mod issue_10662_tests; #[cfg(test)] +mod issue_10760_tests; +#[cfg(test)] mod issue_6585_tests; #[cfg(test)] mod parcel_watcher_tests; diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 6b8060127a..c0296406d7 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -1522,7 +1522,7 @@ fn cyclic_missing_property_names( .filter_map(|capture| capture.get(1).map(|name| name.as_str().to_string())) .collect::>(); let masked_source = super::detect::strip_comments_and_strings(source); - let mut missing = std::collections::BTreeSet::new(); + let mut candidate_sites = Vec::new(); for alias in aliases { let access = perry_perex::tooling::Regex::new(&format!( r#"(?:^|[^A-Za-z0-9_$]){}\.([A-Za-z_$][A-Za-z0-9_$]*)"#, @@ -1532,11 +1532,17 @@ fn cyclic_missing_property_names( for capture in access.captures_iter(&masked_source) { if let Some(property) = capture.get(1).map(|name| name.as_str()) { if !assigned_before.contains(property) { - missing.insert(property.to_string()); + candidate_sites.push((capture.get(0).unwrap().start(), property.to_string())); } } } } + let offsets: Vec = candidate_sites.iter().map(|(offset, _)| *offset).collect(); + let deferred = super::extract_requires::deferred_function_sites(&masked_source, &offsets); + let missing: std::collections::BTreeSet = candidate_sites + .into_iter() + .filter_map(|(offset, property)| (!deferred.contains(&offset)).then_some(property)) + .collect(); missing.into_iter().collect() } diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10178.rs b/crates/perry/tests/source_graph_export_regressions/issue_10178.rs index d7e28ab528..22fa85f3a2 100644 --- a/crates/perry/tests/source_graph_export_regressions/issue_10178.rs +++ b/crates/perry/tests/source_graph_export_regressions/issue_10178.rs @@ -95,3 +95,33 @@ fn missing_property_in_a_cycle_still_warns() { "Accessing non-existent property 'after' of module exports inside circular dependency" )); } + +#[test] +fn deferred_property_read_in_a_cycle_does_not_warn() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "a.cjs", + "const b = require('./b.cjs');\n\ + exports.after = 42;\n\ + exports.read = b.read;\n", + ); + write( + dir.path(), + "b.cjs", + "const a = require('./a.cjs');\n\ + exports.read = function () { return a.after; };\n", + ); + write( + dir.path(), + "main.mjs", + "import a from './a.cjs';\nconsole.log(a.read());\n", + ); + let run = compile_and_run_output(dir.path(), "main.mjs"); + assert_eq!(String::from_utf8_lossy(&run.stdout), "42\n"); + assert!( + run.stderr.is_empty(), + "deferred property read emitted a warning: {}", + String::from_utf8_lossy(&run.stderr) + ); +} From 6e61a08113c70e1a4ca38c4fbb294a86d4309b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:57:45 +0200 Subject: [PATCH 15/32] docs: note CJS circular warning fix --- changelog.d/11013-cjs-circular-warnings.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/11013-cjs-circular-warnings.md diff --git a/changelog.d/11013-cjs-circular-warnings.md b/changelog.d/11013-cjs-circular-warnings.md new file mode 100644 index 0000000000..2362d4610c --- /dev/null +++ b/changelog.d/11013-cjs-circular-warnings.md @@ -0,0 +1,3 @@ +### Fixed + +- CommonJS cycles no longer emit missing-property warnings for reads inside functions that run after module initialization. This removes spurious warnings from iovalkey while retaining warnings for missing properties read during a cycle. From 46f97d8ff9b7d153527a5b1265fd50deb1822fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:58:42 +0200 Subject: [PATCH 16/32] docs: describe circular warning scan scope precisely --- changelog.d/11013-cjs-circular-warnings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/11013-cjs-circular-warnings.md b/changelog.d/11013-cjs-circular-warnings.md index 2362d4610c..19081e080b 100644 --- a/changelog.d/11013-cjs-circular-warnings.md +++ b/changelog.d/11013-cjs-circular-warnings.md @@ -1,3 +1,3 @@ ### Fixed -- CommonJS cycles no longer emit missing-property warnings for reads inside functions that run after module initialization. This removes spurious warnings from iovalkey while retaining warnings for missing properties read during a cycle. +- CommonJS cycles no longer emit missing-property warnings at `require` time for reads inside non-immediately-invoked functions. This removes spurious warnings from iovalkey while retaining warnings for missing properties read directly during a cycle. From 01e35a5d336df498d32026744d8d24521c5b9dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:08:26 +0200 Subject: [PATCH 17/32] ci: type-check Windows runtime on every PR --- .github/workflows/test.yml | 41 +++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a55a2295b..4848c16aba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,9 +160,9 @@ jobs: # --------------------------------------------------------------------------- - # Lint: cargo fmt --check + every no-compile audit script. Runs in EVERY - # tier, including docs-only PRs (it is the one job the plan never turns - # off). ~4 min. Add the ci_plan self-test here too so a policy edit that + # Lint: cargo fmt --check, no-compile audits, and a Windows type-check. + # Runs in EVERY tier, including docs-only PRs (it is the one job the plan + # never turns off). Add the ci_plan self-test here too so a policy edit that # breaks its own invariants is red before it can plan anything. # --------------------------------------------------------------------------- lint: @@ -233,6 +233,41 @@ jobs: # Avoids cache thrash from short-lived branches. save-if: ${{ github.ref == 'refs/heads/main' }} + # #10986: native Windows builds run in sweep/full tiers, so PRs had no + # required signal for cfg(windows) type errors. Bare cargo check on a + # Linux host stops in the mimalloc/zstd C build scripts before checking + # Perry source; cargo-xwin supplies the MSVC CRT and headers. + - name: Install cargo-xwin for Windows type-check + if: ${{ !cancelled() }} + uses: taiki-e/install-action@d438492cf8a250514fa2d34b30bc3c0dc37c65ff # v2.87.8 + with: + tool: cargo-xwin@0.23.0 + fallback: none + + - name: Restore cargo-xwin MSVC sysroot + if: ${{ !cancelled() }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/cargo-xwin + key: cargo-xwin-${{ runner.os }}-0.23.0 + + - name: Type-check Windows runtime and stdlib + if: ${{ !cancelled() }} + env: + # Keep cross-target objects out of lint's shared Linux Rust cache. + CARGO_TARGET_DIR: ${{ runner.temp }}/perry-windows-typecheck + run: | + rustup target add x86_64-pc-windows-msvc + export PATH="$LLVM_SYS_221_PREFIX/bin:$PATH" + cargo xwin check -p perry-runtime -p perry-stdlib --target x86_64-pc-windows-msvc + + - name: Save cargo-xwin MSVC sysroot (main-line runs only) + if: always() && github.event_name != 'pull_request' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/cargo-xwin + key: cargo-xwin-${{ runner.os }}-0.23.0 + - name: Check formatting if: ${{ !cancelled() }} run: cargo fmt --all -- --check From ad428655142465f5150e04e0754963d907f7ff6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:12:11 +0200 Subject: [PATCH 18/32] ci: install cargo-xwin from verified release asset --- .github/workflows/test.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4848c16aba..f58494a288 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -239,10 +239,15 @@ jobs: # Perry source; cargo-xwin supplies the MSVC CRT and headers. - name: Install cargo-xwin for Windows type-check if: ${{ !cancelled() }} - uses: taiki-e/install-action@d438492cf8a250514fa2d34b30bc3c0dc37c65ff # v2.87.8 - with: - tool: cargo-xwin@0.23.0 - fallback: none + run: | + asset=cargo-xwin-v0.23.0.x86_64-unknown-linux-musl.tar.gz + url="https://github.com/rust-cross/cargo-xwin/releases/download/v0.23.0/$asset" + curl --fail --location --retry 3 "$url" --output "$RUNNER_TEMP/$asset" + echo "74a216f64f10ea81c909f02d6b1a84cd0fda8de4c87ee52fe63ba76ab2392b75 $RUNNER_TEMP/$asset" \ + | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/cargo-xwin-bin" + tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/cargo-xwin-bin" + echo "$RUNNER_TEMP/cargo-xwin-bin" >> "$GITHUB_PATH" - name: Restore cargo-xwin MSVC sysroot if: ${{ !cancelled() }} @@ -255,7 +260,7 @@ jobs: if: ${{ !cancelled() }} env: # Keep cross-target objects out of lint's shared Linux Rust cache. - CARGO_TARGET_DIR: ${{ runner.temp }}/perry-windows-typecheck + CARGO_TARGET_DIR: /tmp/perry-windows-typecheck run: | rustup target add x86_64-pc-windows-msvc export PATH="$LLVM_SYS_221_PREFIX/bin:$PATH" From d4c96df3cb9918345c44a2eed8ff7633bf7be7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:31:57 +0200 Subject: [PATCH 19/32] ci: allow cold Windows type-check to finish --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f58494a288..1583e93c29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -170,9 +170,10 @@ jobs: if: fromJSON(needs.plan.outputs.plan).jobs.lint # Was macos-14 — moved to ubuntu-latest in v0.5.428 since `cargo fmt # --check` is portable. The 6 multiplier-min cut is small in absolute - # terms (lint runs in ~30s) but it's free. + # terms on a warm cache. #10986's first cargo-xwin run spends ~15 minutes + # downloading the MSVC sysroot and checking cold; main caches the sysroot. runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 From 9bd6f66ac753a453f98b5497bfcd7bcf4ba909a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:26:51 +0200 Subject: [PATCH 20/32] test(gc): run remembered coverage sabotage in release --- .../perry-runtime/src/gc/sticky_remembered.rs | 6 ++-- .../src/gc/tests/copy_slot_decode.rs | 18 ++++------- crates/perry-runtime/src/gc/verify.rs | 30 ++++++++++--------- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/crates/perry-runtime/src/gc/sticky_remembered.rs b/crates/perry-runtime/src/gc/sticky_remembered.rs index 3402f6cbd7..a73762df3b 100644 --- a/crates/perry-runtime/src/gc/sticky_remembered.rs +++ b/crates/perry-runtime/src/gc/sticky_remembered.rs @@ -76,9 +76,9 @@ impl StickyRememberedSet { } /// How many of this set's entries the remembered set does NOT hold yet — - /// what `restore` would add. Read-only: the debug check of the coverage - /// restore asks this about objects it skipped. - #[cfg(debug_assertions)] + /// what `restore` would add. Read-only: the test/debug check of the + /// coverage restore asks this about objects it skipped. + #[cfg(any(test, debug_assertions))] pub(super) fn count_not_yet_dirty(&self) -> usize { let old_missing = super::barrier::DIRTY_OLD_PAGES.with(|s| { let s = s.borrow(); diff --git a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs index c18c8ad638..8cb86121f1 100644 --- a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs +++ b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs @@ -124,19 +124,13 @@ fn an_old_parents_edge_is_remembered_from_the_child_the_visit_decoded() { ); } -/// In a release build `restore_surviving_dirty_coverage` would re-add the page -/// the arm failed to remember, which is why a forgotten remembered-set entry -/// is invisible to a survival check alone. In the debug build `cargo test` -/// runs, the same walk cross-checks the dirty scan's per-slot re-remembering -/// and refuses the disagreement — that refusal is this twin's observable. +/// In a production release build `restore_surviving_dirty_coverage` would +/// re-add the page the arm failed to remember, which is why a forgotten +/// remembered-set entry is invisible to a survival check alone. Unit-test +/// builds retain the same cross-check as debug builds, so this sabotage twin +/// proves the dirty scan's per-slot re-remembering remains load-bearing under +/// `cargo test --release` as well. #[test] -// Debug-only by construction, as the doc comment above already states: in a -// release build `restore_surviving_dirty_coverage` re-adds the page, so the -// refusal this asserts never happens. Ignored rather than cfg'd out so the -// release run still reports it by name. Do NOT "fix" the test: it is correct, -// the profile changed what the code means. `[profile.gcaudit]` gives release -// codegen with assertions live and is where to exercise this under release. -#[cfg_attr(not(debug_assertions), ignore = "asserts a debug-only cross-check")] fn sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check() { let outcome = old_edge_across_two_minors(true); assert!( diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index 12e94ba7d0..3fad50068f 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -339,9 +339,11 @@ pub(super) unsafe fn remember_evacuated_old_copy_young_slots( /// pages the sticky restore just inserted. They are skipped; the walk is then /// proportional to the objects the dirty scan could NOT fully cover /// (multi-page arrays, owners of out-of-body buffers) instead of to every slot -/// on every dirty page. Under `debug_assertions` the skipped objects are -/// walked anyway and any page the walk would have ADDED is a panic — the -/// machine check of the equivalence argument above. +/// on every dirty page. Under `debug_assertions`, and in unit-test builds, the +/// skipped objects are walked anyway and any page the walk would have ADDED is +/// a panic — the machine check of the equivalence argument above. Including +/// `cfg(test)` keeps the check active in `cargo test --release` without adding +/// work to production release builds. pub(super) fn restore_surviving_dirty_coverage( snapshot: &RememberedDirtySnapshot, covered: &crate::fast_hash::PtrHashSet, @@ -367,7 +369,7 @@ fn restore_surviving_dirty_coverage_impl( let mut parents_visited = 0usize; let mut slots_visited = 0usize; let mut slots_tracking = 0usize; - #[cfg(debug_assertions)] + #[cfg(any(test, debug_assertions))] let mut skipped_sticky = StickyRememberedSet::default(); // Mirror scan_remembered_dirty_slots_copying's scan_header guards: the // external dirty entries can carry headers the harness seeded @@ -417,8 +419,8 @@ fn restore_surviving_dirty_coverage_impl( crate::arena::old_arena_walk_objects_on_pages(&snapshot.dirty_old_pages, |hp| { if covered.contains(&(hp as usize)) { skipped += 1; - #[cfg(debug_assertions)] - debug_visit_covered_parent(hp as *mut GcHeader, &mut skipped_sticky); + #[cfg(any(test, debug_assertions))] + cross_check_covered_parent(hp as *mut GcHeader, &mut skipped_sticky); return; } walked += 1; @@ -432,8 +434,8 @@ fn restore_surviving_dirty_coverage_impl( } if covered.contains(&header_addr) { skipped += 1; - #[cfg(debug_assertions)] - debug_visit_covered_parent(header_addr as *mut GcHeader, &mut skipped_sticky); + #[cfg(any(test, debug_assertions))] + cross_check_covered_parent(header_addr as *mut GcHeader, &mut skipped_sticky); continue; } walked += 1; @@ -456,7 +458,7 @@ fn restore_surviving_dirty_coverage_impl( } } let added = sticky.restore_counted(); - #[cfg(debug_assertions)] + #[cfg(any(test, debug_assertions))] { let would_add = skipped_sticky.count_not_yet_dirty(); assert_eq!( @@ -484,11 +486,11 @@ fn restore_surviving_dirty_coverage_impl( } } -/// Debug twin of the restore's `visit_parent` for a skipped object: re-derive -/// what the full walk would have remembered so the caller can assert it adds -/// nothing beyond what the dirty scan already restored. -#[cfg(debug_assertions)] -fn debug_visit_covered_parent(header: *mut GcHeader, sticky: &mut StickyRememberedSet) { +/// Test/debug twin of the restore's `visit_parent` for a skipped object: +/// re-derive what the full walk would have remembered so the caller can assert +/// it adds nothing beyond what the dirty scan already restored. +#[cfg(any(test, debug_assertions))] +fn cross_check_covered_parent(header: *mut GcHeader, sticky: &mut StickyRememberedSet) { unsafe { if header.is_null() { return; From 003724cd953f4bde21530063c69285c23677a6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:37:44 +0200 Subject: [PATCH 21/32] docs: note release GC sabotage coverage --- changelog.d/11027-release-gc-sabotage.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11027-release-gc-sabotage.md diff --git a/changelog.d/11027-release-gc-sabotage.md b/changelog.d/11027-release-gc-sabotage.md new file mode 100644 index 0000000000..bb4eaf75de --- /dev/null +++ b/changelog.d/11027-release-gc-sabotage.md @@ -0,0 +1,5 @@ +### Tests + +The remembered-set coverage sabotage witness now runs under release unit tests, +so the dirty-scan remembering arm can no longer regress behind a silently +ignored debug-only assertion. From 9c5417432e6f9d6cab9aee1f769ccb78daf0d652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:49:44 +0200 Subject: [PATCH 22/32] fix(crypto): preserve bytes in latin1 digests --- crates/perry-stdlib/src/crypto/hash_handles.rs | 10 +++++++--- test-files/test_gap_10473_crypto_digest_latin1.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 test-files/test_gap_10473_crypto_digest_latin1.ts diff --git a/crates/perry-stdlib/src/crypto/hash_handles.rs b/crates/perry-stdlib/src/crypto/hash_handles.rs index 1632e77a7b..a947690f0e 100644 --- a/crates/perry-stdlib/src/crypto/hash_handles.rs +++ b/crates/perry-stdlib/src/crypto/hash_handles.rs @@ -179,12 +179,16 @@ fn finalize_hmac_state(state: Option) -> Vec { } } +fn latin1_string(bytes: &[u8]) -> String { + bytes.iter().map(|&byte| char::from(byte)).collect() +} + fn encoded_digest(bytes: &[u8], encoding: &str) -> String { match encoding { "hex" => hex::encode(bytes), "base64" => base64::engine::general_purpose::STANDARD.encode(bytes), "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes), - "binary" | "latin1" => String::from_utf8_lossy(bytes).into_owned(), + "binary" | "latin1" => latin1_string(bytes), _ => String::from_utf8_lossy(bytes).into_owned(), } } @@ -498,7 +502,7 @@ pub unsafe fn dispatch_hash(handle: i64, method: &str, args: &[f64]) -> f64 { "hex" => hex::encode(&digest), "base64" => base64::engine::general_purpose::STANDARD.encode(&digest), "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest), - "binary" | "latin1" => String::from_utf8_lossy(&digest).into_owned(), + "binary" | "latin1" => latin1_string(&digest), _ => hex::encode(&digest), }; let s = js_string_from_bytes(encoded.as_ptr(), encoded.len() as u32); @@ -754,7 +758,7 @@ pub unsafe fn dispatch_hmac(handle: i64, method: &str, args: &[f64]) -> f64 { "hex" => hex::encode(&digest), "base64" => base64::engine::general_purpose::STANDARD.encode(&digest), "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest), - "binary" | "latin1" => String::from_utf8_lossy(&digest).into_owned(), + "binary" | "latin1" => latin1_string(&digest), _ => hex::encode(&digest), }; let s = js_string_from_bytes(encoded.as_ptr(), encoded.len() as u32); diff --git a/test-files/test_gap_10473_crypto_digest_latin1.ts b/test-files/test_gap_10473_crypto_digest_latin1.ts new file mode 100644 index 0000000000..78ae49a57c --- /dev/null +++ b/test-files/test_gap_10473_crypto_digest_latin1.ts @@ -0,0 +1,15 @@ +import crypto from "node:crypto"; + +function report(label: string, value: string, expectedHex: string) { + const codes = Array.from(value.slice(0, 6), (char) => char.charCodeAt(0)).join(","); + const roundTrips = Buffer.from(value, "latin1").toString("hex") === expectedHex; + console.log(label, value.length, codes, roundTrips); +} + +const hashHex = crypto.createHash("sha256").update("abc").digest("hex"); +report("hash latin1", crypto.createHash("sha256").update("abc").digest("latin1"), hashHex); +report("hash binary", crypto.createHash("sha256").update("abc").digest("binary"), hashHex); + +const hmacHex = crypto.createHmac("sha256", "k").update("abc").digest("hex"); +report("hmac latin1", crypto.createHmac("sha256", "k").update("abc").digest("latin1"), hmacHex); +report("hmac binary", crypto.createHmac("sha256", "k").update("abc").digest("binary"), hmacHex); From eaf51470c63ae163a72c50b716aab3a78497197f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:50:53 +0200 Subject: [PATCH 23/32] docs: add changelog for #11029 --- changelog.d/11029-crypto-latin1-digests.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/11029-crypto-latin1-digests.md diff --git a/changelog.d/11029-crypto-latin1-digests.md b/changelog.d/11029-crypto-latin1-digests.md new file mode 100644 index 0000000000..f1c15f5a20 --- /dev/null +++ b/changelog.d/11029-crypto-latin1-digests.md @@ -0,0 +1,6 @@ +### Fixed + +- `Hash.digest()` and `Hmac.digest()` now preserve every digest byte when + called with `"latin1"` or its `"binary"` alias. Bytes above `0x7f` no longer + become replacement characters, and the resulting string round-trips through + `Buffer.from(value, "latin1")` without corruption or length changes. From f8db4366a6e6ee8ca9ca17de58c60d4f91427c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:02:47 +0200 Subject: [PATCH 24/32] fix(crypto): honor Sign and Verify encodings --- crates/perry-stdlib/src/crypto/ecdh.rs | 34 +++-- ...t_gap_10472_crypto_sign_verify_encoding.ts | 141 ++++++++++++++++++ 2 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 test-files/test_gap_10472_crypto_sign_verify_encoding.ts diff --git a/crates/perry-stdlib/src/crypto/ecdh.rs b/crates/perry-stdlib/src/crypto/ecdh.rs index b90fc129bd..2b31d03e97 100644 --- a/crates/perry-stdlib/src/crypto/ecdh.rs +++ b/crates/perry-stdlib/src/crypto/ecdh.rs @@ -136,6 +136,13 @@ pub unsafe extern "C" fn js_crypto_ecdh_convert_key( ) } +unsafe fn signature_output(bytes: &[u8], encoding: Option) -> f64 { + match encoding { + Some(tag) => encode_bytes_with_tag(bytes, tag), + None => nanbox_ptr(alloc_buffer_from_slice(bytes)), + } +} + pub unsafe fn dispatch_sign(handle: i64, method: &str, args: &[f64]) -> f64 { let h = match get_handle_mut::(handle) { Some(h) => h, @@ -161,6 +168,7 @@ pub unsafe fn dispatch_sign(handle: i64, method: &str, args: &[f64]) -> f64 { // The handle is consumed by `.sign()` regardless of outcome. h.finalized .store(true, std::sync::atomic::Ordering::Relaxed); + let output_encoding = encoding_tag_from_arg(args.get(1).copied()); let key_bits = args[0].to_bits(); let pem = match crypto_key_input_to_private_pem(key_bits) { Some(pem) => pem, @@ -171,16 +179,10 @@ pub unsafe fn dispatch_sign(handle: i64, method: &str, args: &[f64]) -> f64 { let signature: P256EcdsaSignature = signing_key.sign(&data); if key_input_uses_ieee_p1363(key_bits) { let raw = signature.to_bytes(); - let buf = alloc_buffer_from_slice(raw.as_slice()); - return f64::from_bits( - 0x7FFD_0000_0000_0000u64 | ((buf as u64) & 0x0000_FFFF_FFFF_FFFF), - ); + return signature_output(raw.as_slice(), output_encoding); } let der = signature.to_der(); - let buf = alloc_buffer_from_slice(der.as_bytes()); - return f64::from_bits( - 0x7FFD_0000_0000_0000u64 | ((buf as u64) & 0x0000_FFFF_FFFF_FFFF), - ); + return signature_output(der.as_bytes(), output_encoding); } let private_key = match parse_rsa_private_key_pem(&pem) { Some(key) => key, @@ -193,8 +195,7 @@ pub unsafe fn dispatch_sign(handle: i64, method: &str, args: &[f64]) -> f64 { } else { sign_rsa_data(h.alg, private_key, &data) }; - let buf = alloc_buffer_from_slice(&signature); - f64::from_bits(0x7FFD_0000_0000_0000u64 | ((buf as u64) & 0x0000_FFFF_FFFF_FFFF)) + signature_output(&signature, output_encoding) } _ => f64::from_bits(0x7FFC_0000_0000_0001), } @@ -486,13 +487,21 @@ pub unsafe fn dispatch_verify(handle: i64, method: &str, args: &[f64]) -> f64 { h.finalized .store(true, std::sync::atomic::Ordering::Relaxed); let key_bits = args[0].to_bits(); - let sig_ptr = (args[1].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + // Node applies `signatureEncoding` only to string signatures. + // Buffer inputs keep their bytes and ignore even an invalid third + // argument. A string with no encoding defaults to UTF-8. + let sig_bytes = if let Some(signature) = string_from_jsvalue(args[1].to_bits()) { + let encoding = + encoding_tag_from_arg(args.get(2).copied()).unwrap_or(EncodingTag(0)); + decode_string_bytes_with_tag(signature.as_bytes(), encoding) + } else { + bytes_from_ptr(arg_ptr(args[1])) + }; let pem = match crypto_key_input_to_public_pem(key_bits) { Some(pem) => pem, None => return js_bool(false), }; if let Some(verifying_key) = parse_p256_verifying_key_pem(&pem) { - let sig_bytes = bytes_from_ptr(sig_ptr); let signature = if key_input_uses_ieee_p1363(key_bits) { P256EcdsaSignature::from_slice(&sig_bytes) } else { @@ -509,7 +518,6 @@ pub unsafe fn dispatch_verify(handle: i64, method: &str, args: &[f64]) -> f64 { Some(key) => key, None => return js_bool(false), }; - let sig_bytes = bytes_from_ptr(sig_ptr); if key_input_uses_rsa_pss(key_bits) { let signature = match RsaPssSignature::try_from(sig_bytes.as_slice()) { Ok(sig) => sig, diff --git a/test-files/test_gap_10472_crypto_sign_verify_encoding.ts b/test-files/test_gap_10472_crypto_sign_verify_encoding.ts new file mode 100644 index 0000000000..ebccdc6ada --- /dev/null +++ b/test-files/test_gap_10472_crypto_sign_verify_encoding.ts @@ -0,0 +1,141 @@ +// #10472 — Sign.sign must encode string output and Verify.verify must decode +// string signatures with the optional encoding argument. +import crypto from "node:crypto"; + +const privateKey = + "-----BEGIN PRIVATE KEY-----\n" + + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzXENgrYISpXh8UGG\n" + + "n6gRdTvn03fTF16DgDQTADm0XV6hRANCAAS86N32ME7tVKj5oIMLOiYoElFNSXbJ\n" + + "wMQL3GyWDLKC996gWUP4WfQLYOJd6To9wdlomuiOFtVryzwKdMdFFd7G\n" + + "-----END PRIVATE KEY-----\n"; +const publicKey = + "-----BEGIN PUBLIC KEY-----\n" + + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvOjd9jBO7VSo+aCDCzomKBJRTUl2\n" + + "ycDEC9xslgyygvfeoFlD+Fn0C2DiXek6PcHZaJrojhbVa8s8CnTHRRXexg==\n" + + "-----END PUBLIC KEY-----\n"; +const rsaPrivateKey = + "-----BEGIN PRIVATE KEY-----\n" + + "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALA9qACs400Jgizt\n" + + "8uNV2sw/+Qj1V6/27b50gH4LBC81YPypipBTZ52mbb4Xfpr5OroUnWCxaibEj0rg\n" + + "2nlKuS6wSCOrEdsdRC40GoLeSnIDExDgYVTWlEiU2dZ2bAKqSO3l7JRBQyJkwfBG\n" + + "qJRHTtrZ3ycKmQTvlcZJw+p5r48fAgMBAAECgYA7hxfP4pWD18pYUqbPkpgslQ8Q\n" + + "r43Gqajzw3YDHMV1DJqNvNZImWNOJIC8zEK/JZ9oar4dgs9P+ORNblVc0phpVSQ4\n" + + "lKjOQguiFgZqjbEL1tQTpObQmf711ZcWOMiFweDKbT0foW1b+0BnzLVLQHsrnItv\n" + + "obASCIEv9vKytN1wwQJBANq7Q8811TaV5xrlzvITBIZxO/g8oneaHRfxLLGNBuWh\n" + + "TLPkzkJh2higWM0nKk6lcyKwCzAqI5DKMkaXpvmoBoMCQQDORQN37EXMed080eLI\n" + + "RwuyG+2ZGoxtyQtUyoznlIXHWsoE4uUBmQ4YjCNljhbqPz0RTvrDxPj0uzJnP2Vd\n" + + "+xI1AkEAzjuC0/yN68mq/VFwrg4AVkKtqICDLwHALLLY0Q+HUTukdnllgHGCkXWe\n" + + "RNCIs16MEEisQ913ay05+hVC+mHSwQJBAKl+4mu/9lcg6KBao+0JHF4+Ps6ply17\n" + + "n9kMHB8L16ZKP2kmfSIEACZBubBwwvm3/1liugL2r9CCptdaq9Q/ROUCQBWhzBBS\n" + + "LbtQCOQOxrzUW6ipqfEeoHWEEZI++krTqYFsZL62uZ86b6gLyQLLRSsngr7D4D/w\n" + + "+89KirPwQz+VOmA=\n" + + "-----END PRIVATE KEY-----\n"; +const rsaPublicKey = + "-----BEGIN PUBLIC KEY-----\n" + + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwPagArONNCYIs7fLjVdrMP/kI\n" + + "9Vev9u2+dIB+CwQvNWD8qYqQU2edpm2+F36a+Tq6FJ1gsWomxI9K4Np5SrkusEgj\n" + + "qxHbHUQuNBqC3kpyAxMQ4GFU1pRIlNnWdmwCqkjt5eyUQUMiZMHwRqiUR07a2d8n\n" + + "CpkE75XGScPqea+PHwIDAQAB\n" + + "-----END PUBLIC KEY-----\n"; +const payload = "header.payload"; + +function sign(key: any, encoding?: BufferEncoding): Buffer | string { + const signer = crypto.createSign("sha256"); + signer.update(payload); + return encoding === undefined ? signer.sign(key) : signer.sign(key, encoding); +} + +function verify( + key: any, + signature: Buffer | string, + encoding?: BufferEncoding, +): boolean { + const verifier = crypto.createVerify("sha256"); + verifier.update(payload); + return encoding === undefined + ? verifier.verify(key, signature) + : verifier.verify(key, signature, encoding); +} + +for (const encoding of ["hex", "base64", "base64url", "latin1", "binary"] as const) { + const signature = sign(privateKey, encoding); + console.log( + "encoded", + encoding, + typeof signature, + Buffer.isBuffer(signature), + verify(publicKey, signature, encoding), + ); +} + +const p1363Private = { key: privateKey, dsaEncoding: "ieee-p1363" as const }; +const p1363Public = { key: publicKey, dsaEncoding: "ieee-p1363" as const }; +const p1363 = sign(p1363Private, "base64url"); +console.log( + "p1363", + typeof p1363, + Buffer.isBuffer(p1363), + verify(p1363Public, p1363, "base64url"), +); + +const rsa = sign(rsaPrivateKey, "base64"); +console.log( + "rsa", + typeof rsa, + Buffer.isBuffer(rsa), + verify(rsaPublicKey, rsa, "base64"), +); + +const rsaRaw = sign(rsaPrivateKey) as Buffer; +for (const encoding of ["utf8", "ascii", "utf16le"] as const) { + const encoded = sign(rsaPrivateKey, encoding); + console.log( + "encoded-lossy", + encoding, + typeof encoded, + Buffer.isBuffer(encoded), + encoded === rsaRaw.toString(encoding), + ); +} + +const rsaPssPrivate = { + key: rsaPrivateKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: 32, +}; +const rsaPssPublic = { + key: rsaPublicKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: 32, +}; +const rsaPss = sign(rsaPssPrivate, "hex"); +console.log( + "rsa-pss", + typeof rsaPss, + Buffer.isBuffer(rsaPss), + verify(rsaPssPublic, rsaPss, "hex"), +); + +const raw = sign(privateKey) as Buffer; +console.log( + "buffer-invalid-encoding-ignored", + verify(publicKey, raw, "not-an-encoding" as any), +); + +try { + sign(privateKey, "not-an-encoding" as any); + console.log("sign-invalid no-throw"); +} catch (error: any) { + console.log("sign-invalid", error.code); +} + +try { + verify( + publicKey, + (sign(privateKey, "hex") as string), + "not-an-encoding" as any, + ); + console.log("verify-invalid no-throw"); +} catch (error: any) { + console.log("verify-invalid", error.code); +} From b37cef5d2dc483e9bb5e5e4b5ca94cd5f3184f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:05:13 +0200 Subject: [PATCH 25/32] docs: note crypto signature encoding fix --- changelog.d/11030-crypto-sign-encoding.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11030-crypto-sign-encoding.md diff --git a/changelog.d/11030-crypto-sign-encoding.md b/changelog.d/11030-crypto-sign-encoding.md new file mode 100644 index 0000000000..48088ac4f3 --- /dev/null +++ b/changelog.d/11030-crypto-sign-encoding.md @@ -0,0 +1,5 @@ +### Fixed + +`Sign.sign(key, encoding)` now returns the requested encoded string, and +`Verify.verify(key, signature, encoding)` decodes string signatures using the +same encoding rules as Node and Buffer. From 2ab6db876f47240273f86344ded0f57503530362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:13:16 +0200 Subject: [PATCH 26/32] ci: run lock downgrade gate on pull requests --- .github/workflows/test.yml | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1583e93c29..c729fd649c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -877,14 +877,30 @@ jobs: if: ${{ !cancelled() }} run: | python3 scripts/global_sink_isolation.py --self-test - # #10980: the lock-downgrade gate runs at MERGE time, not per-PR - # (a branch that merely predates a bump is not at fault, and failing - # those trains people to bypass it). Only its self-test belongs here - # -- it proves the detector still catches a downgrade and still - # passes every legitimate move. - python3 scripts/lock_no_downgrade.py --self-test python3 scripts/global_sink_isolation.py + # #10980/#11025: Cargo.lock is a shipping input, and a regenerated lock + # can lower one consumer's resolved dependency while a newer version of + # that dependency remains elsewhere in the graph. Keep the detector's + # proof independent from unrelated lint steps so it always reports. + - name: Lockfile downgrade detector self-test + if: ${{ !cancelled() }} + run: python3 scripts/lock_no_downgrade.py --self-test + + # actions/checkout checks out the synthetic merge commit for a + # pull_request event. Comparing that tree with the event's base SHA asks + # the useful question: would THIS merge move any shared consumer's pin + # backwards? A branch that merely predates a bump inherits the base's + # lock in the merge result and stays green. + - name: Cargo.lock may not downgrade vs. pull request base + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null \ + || git fetch --no-tags --depth=1 origin "$BASE_SHA" + python3 scripts/lock_no_downgrade.py --vs "$BASE_SHA" + # #10944's asserted-global ratchet, merge-base half. The gate above # fails on a NEW bare process-global that a test asserts on; this # rejects the other way round it -- a diff that adds one and records it From ccff0d81699e9d0adcb56bddc19d647a09528e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:14:34 +0200 Subject: [PATCH 27/32] docs: note pull request lock downgrade gate --- changelog.d/11032-lock-downgrade-pr-gate.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11032-lock-downgrade-pr-gate.md diff --git a/changelog.d/11032-lock-downgrade-pr-gate.md b/changelog.d/11032-lock-downgrade-pr-gate.md new file mode 100644 index 0000000000..df87f86d57 --- /dev/null +++ b/changelog.d/11032-lock-downgrade-pr-gate.md @@ -0,0 +1,5 @@ +### Fixed + +Pull request CI now rejects `Cargo.lock` changes that lower a dependency for a +shared consumer, including `tempfile` resolving `getrandom` from 0.4.2 back to +0.3.4, before the merge can silently undo a newer pin. From ea7faa28ca63472050aea1825b817dbf60edc93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:31:27 +0200 Subject: [PATCH 28/32] fix(fs): defer read stream open errors --- crates/perry-runtime/src/fs/stream.rs | 23 +++++++--- .../src/fs/stream/options_init.rs | 1 + .../perry-runtime/src/fs/stream/read_turn.rs | 45 +++++++++++++++++++ .../src/fs/stream/stream_errors.rs | 10 +++-- ...st_gap_10543_fs_read_stream_error_order.ts | 12 +++++ 5 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 crates/perry-runtime/src/fs/stream/read_turn.rs create mode 100644 test-files/test_gap_10543_fs_read_stream_error_order.ts diff --git a/crates/perry-runtime/src/fs/stream.rs b/crates/perry-runtime/src/fs/stream.rs index a341d43bc0..0ed1721d77 100644 --- a/crates/perry-runtime/src/fs/stream.rs +++ b/crates/perry-runtime/src/fs/stream.rs @@ -91,7 +91,7 @@ pub(crate) struct StreamState { /// #10451: a read stream's constructor-time open failure, held as the OS /// error until `store_open_failure` turns it into `error_value`. open_failure: Option, - /// #9493: a turn is already parked on the callback-timer queue. + /// A stream turn is already parked on the callback-timer queue. turn_pending: bool, bytes_read: u64, bytes_written: u64, @@ -1238,6 +1238,8 @@ fn throw_plain_type_error_value(message: &str) -> ! { mod options_init; use options_init::*; +mod read_turn; +use read_turn::*; mod stream_errors; use stream_errors::*; mod utf8_stream; @@ -1483,7 +1485,7 @@ pub(crate) extern "C" fn read_stream_on_impl( state.paused = false; } }); - read_stream_pump(id); + schedule_read_stream_turn(id); } current_receiver_value() } @@ -1501,7 +1503,7 @@ pub(crate) extern "C" fn read_stream_once_impl( state.paused = false; } }); - read_stream_pump(id); + schedule_read_stream_turn(id); } current_receiver_value() } @@ -1522,7 +1524,7 @@ pub(crate) extern "C" fn read_stream_pipe_impl( state.paused = false; } }); - read_stream_pump(id); + schedule_read_stream_turn(id); dest } @@ -1542,7 +1544,7 @@ pub(crate) extern "C" fn read_stream_resume_impl(closure: *const ClosureHeader) state.paused = false; } }); - read_stream_pump(id); + schedule_read_stream_turn(id); current_receiver_value() } @@ -1595,7 +1597,7 @@ fn stream_on_common(id: usize, event_value: f64, cb: f64, once: bool) { "ready" if state.kind == StreamKind::Read && state.opened => { Some(("ready", undefined_value())) } - "error" => stored_error_value(state).map(|err| ("error", err)), + "error" if state.errored => stored_error_value(state).map(|err| ("error", err)), "end" if state.kind == StreamKind::Read && state.ended => { Some(("end", undefined_value())) } @@ -1787,6 +1789,15 @@ fn create_read_stream_with_state(state: StreamState) -> f64 { // stream at all. `emit_event0`/`emit_event1` forward to node:stream's listener // registry so the iterator this installs actually receives the chunks. crate::node_stream::async_iterator::install_foreign_readable_async_iterator_symbol(value); + let has_open_failure = STREAM_REGISTRY.with(|registry| { + registry + .borrow() + .get(&id) + .is_some_and(|state| state.error_msg.is_some()) + }); + if has_open_failure { + schedule_read_stream_turn(id); + } value } diff --git a/crates/perry-runtime/src/fs/stream/options_init.rs b/crates/perry-runtime/src/fs/stream/options_init.rs index 51eb810a77..f6b0d5458d 100644 --- a/crates/perry-runtime/src/fs/stream/options_init.rs +++ b/crates/perry-runtime/src/fs/stream/options_init.rs @@ -19,6 +19,7 @@ pub(super) fn register_stream_method_arities() { crate::closure::js_register_closure_arity(read_stream_is_paused_impl as *const u8, 0); crate::closure::js_register_closure_arity(read_stream_close_impl as *const u8, 1); crate::closure::js_register_closure_arity(read_stream_resume_from_drain_impl as *const u8, 0); + crate::closure::js_register_closure_arity(read_stream_turn_impl as *const u8, 0); crate::closure::js_register_closure_arity(utf8_stream_write_impl as *const u8, 1); crate::closure::js_register_closure_arity(utf8_stream_flush_impl as *const u8, 1); crate::closure::js_register_closure_arity(utf8_stream_flush_sync_impl as *const u8, 0); diff --git a/crates/perry-runtime/src/fs/stream/read_turn.rs b/crates/perry-runtime/src/fs/stream/read_turn.rs new file mode 100644 index 0000000000..d39e0332c1 --- /dev/null +++ b/crates/perry-runtime/src/fs/stream/read_turn.rs @@ -0,0 +1,45 @@ +//! Deferred `ReadStream` start and error-close turns. + +use super::*; + +/// Start a readable on a later event-loop turn. Node opens file streams +/// asynchronously, so a caller must be able to finish a chained sequence such +/// as `.on("data", ...).on("error", ...)` before an open failure is emitted. +/// The pending bit coalesces starts from construction, `on("data")`, `pipe`, +/// and `resume` into one callback. +pub(super) fn schedule_read_stream_turn(id: usize) { + let should_schedule = STREAM_REGISTRY.with(|registry| { + let mut registry = registry.borrow_mut(); + let Some(state) = registry.get_mut(&id) else { + return false; + }; + if state.turn_pending || state.closed || state.destroyed { + return false; + } + state.turn_pending = true; + true + }); + if should_schedule { + let closure = js_closure_alloc(read_stream_turn_impl as *const u8, 1); + js_closure_set_capture_ptr(closure, 0, id as i64); + let _ = crate::timer::js_set_timeout_callback(closure as i64, 0.0); + } +} + +pub(super) extern "C" fn read_stream_turn_impl(closure: *const ClosureHeader) -> f64 { + let id = stream_id_of(closure); + let should_close = STREAM_REGISTRY.with(|registry| { + if let Some(state) = registry.borrow_mut().get_mut(&id) { + state.turn_pending = false; + state.kind == StreamKind::Read && state.errored && !state.closed + } else { + false + } + }); + if should_close { + maybe_close_stream(id, false); + } else { + read_stream_pump(id); + } + undefined_value() +} diff --git a/crates/perry-runtime/src/fs/stream/stream_errors.rs b/crates/perry-runtime/src/fs/stream/stream_errors.rs index 5f2e37fadb..69dc55f0c5 100644 --- a/crates/perry-runtime/src/fs/stream/stream_errors.rs +++ b/crates/perry-runtime/src/fs/stream/stream_errors.rs @@ -56,8 +56,8 @@ fn store_read_failure(id: usize, failure: &FsReadFailure) { } /// Turn the constructor's open failure into `error_value` once the state is -/// registered, so the `'error'` replay to a listener attached right after -/// construction and the pump's delivery both hand out the node-shaped value. +/// registered, so the deferred pump hands the `'error'` listener a +/// node-shaped value. pub(super) fn store_open_failure(id: usize) { let failure = STREAM_REGISTRY.with(|registry| { let mut registry = registry.borrow_mut(); @@ -104,6 +104,10 @@ pub(super) fn emit_pending_read_error(id: usize) -> bool { return false; }; record_stream_error(id, message); - maybe_close_stream(id, false); + // Node delivers `close` after `error`. Keeping those on separate turns + // also lets work queued in the construction turn (for example + // `fs.promises.writeFile(out, stream)`) still identify the stream and + // consume its stored open failure. + schedule_read_stream_turn(id); true } diff --git a/test-files/test_gap_10543_fs_read_stream_error_order.ts b/test-files/test_gap_10543_fs_read_stream_error_order.ts new file mode 100644 index 0000000000..ff667ee482 --- /dev/null +++ b/test-files/test_gap_10543_fs_read_stream_error_order.ts @@ -0,0 +1,12 @@ +// Gap test: #10543 — a ReadStream open error must be emitted after the caller +// has had time to attach listeners to the returned stream. +import fs from "node:fs"; + +const missing = "/tmp/perry_gap_10543_missing/nope.txt"; +fs.rmSync("/tmp/perry_gap_10543_missing", { recursive: true, force: true }); + +fs.createReadStream(missing) + .on("data", () => console.log("unexpected data")) + .on("error", (error: any) => console.log("error event", error.code)); + +console.log("sync after createReadStream"); From 25e0268d55a862f244f6c6a52405e55de6533158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:32:10 +0200 Subject: [PATCH 29/32] chore: add changelog for #11033 --- changelog.d/11033-fs-readstream-error-order.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11033-fs-readstream-error-order.md diff --git a/changelog.d/11033-fs-readstream-error-order.md b/changelog.d/11033-fs-readstream-error-order.md new file mode 100644 index 0000000000..3b3fde9e25 --- /dev/null +++ b/changelog.d/11033-fs-readstream-error-order.md @@ -0,0 +1 @@ +Fixed `fs.createReadStream()` open errors firing before callers could attach chained `error` listeners. From 4d235edc09c83543ee5140a5578e4b224e903501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 22:21:11 +0200 Subject: [PATCH 30/32] fix(stream): flush piped transforms before end --- crates/perry-runtime/src/node_stream.rs | 21 ++++--- .../src/node_stream_readwrite.rs | 2 +- crates/perry-runtime/src/node_stream_tests.rs | 61 +++++++++++++++++++ .../test_gap_10450_transform_pipe_flush.ts | 48 +++++++++++++++ 4 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 test-files/test_gap_10450_transform_pipe_flush.ts diff --git a/crates/perry-runtime/src/node_stream.rs b/crates/perry-runtime/src/node_stream.rs index 6db4fda8f0..713986092e 100644 --- a/crates/perry-runtime/src/node_stream.rs +++ b/crates/perry-runtime/src/node_stream.rs @@ -982,13 +982,9 @@ extern "C" fn pipe_drain_callback(closure: *const ClosureHeader) -> f64 { f64::from_bits(TAG_UNDEFINED) } -extern "C" fn pipe_finish_destination_callback(closure: *const ClosureHeader) -> f64 { - if closure.is_null() { - return f64::from_bits(TAG_UNDEFINED); - } - let dest = js_closure_get_capture_f64(closure, 0); +fn finish_pipe_destination(dest: f64) { if stream_destroyed(dest) || has_truthy_hidden(dest, hidden_finish_emitted_key()) { - return f64::from_bits(TAG_UNDEFINED); + return; } if writable_length(dest) > 0.0 { set_hidden_value( @@ -1002,8 +998,17 @@ extern "C" fn pipe_finish_destination_callback(closure: *const ClosureHeader) -> hidden_stream_pipe_end_pending_key(), f64::from_bits(TAG_FALSE), ); - finish_stream(dest, None); + if !finish_transform_stream(dest, None) { + finish_stream(dest, None); + } + } +} + +extern "C" fn pipe_finish_destination_callback(closure: *const ClosureHeader) -> f64 { + if closure.is_null() { + return f64::from_bits(TAG_UNDEFINED); } + finish_pipe_destination(js_closure_get_capture_f64(closure, 0)); f64::from_bits(TAG_UNDEFINED) } @@ -1088,7 +1093,7 @@ fn request_pipe_destination_finish(dest: f64) { ); schedule_pipe_destination_finish_check(dest); } else { - schedule_pipe_destination_finish(dest); + finish_pipe_destination(dest); } } diff --git a/crates/perry-runtime/src/node_stream_readwrite.rs b/crates/perry-runtime/src/node_stream_readwrite.rs index 0af8609b77..e6268d7fd7 100644 --- a/crates/perry-runtime/src/node_stream_readwrite.rs +++ b/crates/perry-runtime/src/node_stream_readwrite.rs @@ -920,7 +920,7 @@ pub(super) fn drain_readable_from_events(stream: f64) { } } } - if !stream_destroyed(stream) { + if !stream_destroyed(stream) && !has_truthy_hidden(stream, hidden_transform_finishing_key()) { emit_readable_end_once(stream); } } diff --git a/crates/perry-runtime/src/node_stream_tests.rs b/crates/perry-runtime/src/node_stream_tests.rs index 1ba98c5c90..69b11548f4 100644 --- a/crates/perry-runtime/src/node_stream_tests.rs +++ b/crates/perry-runtime/src/node_stream_tests.rs @@ -1018,6 +1018,67 @@ fn transform_flush_callback_pushes_tail_before_finish() { }); } +#[test] +fn piped_transform_flush_callback_pushes_tail_before_finish() { + READABLE_DATA_CAPTURED.with(|captured| captured.borrow_mut().clear()); + TRANSFORM_THIS_HAS_STREAM_STATE.with(|matches| matches.borrow_mut().clear()); + TRANSFORM_FLUSH_COUNT.with(|count| *count.borrow_mut() = 0); + + let opts = crate::object::js_object_alloc(0, 2); + let transform_cb = js_closure_alloc(transform_identity_callback as *const u8, 0); + let flush_cb = js_closure_alloc(transform_flush_tail_callback as *const u8, 0); + crate::closure::js_register_closure_arity(transform_identity_callback as *const u8, 3); + crate::closure::js_register_closure_arity(transform_flush_tail_callback as *const u8, 1); + js_object_set_field_by_name( + opts, + hidden_key(b"transform"), + box_pointer(transform_cb as *const u8), + ); + js_object_set_field_by_name( + opts, + hidden_key(b"flush"), + box_pointer(flush_cb as *const u8), + ); + + let source = js_node_stream_passthrough_new(f64::from_bits(TAG_UNDEFINED)); + let destination = js_node_stream_transform_new(box_pointer(opts as *const u8)); + let destination_handle = raw_ptr_from_value(destination) as i64; + let data_closure = js_closure_alloc(capture_data_listener as *const u8, 1); + crate::closure::js_register_closure_arity(capture_data_listener as *const u8, 1); + crate::closure::js_closure_set_capture_f64(data_closure, 0, destination); + let _ = js_node_stream_method_on( + destination_handle, + string_value("data"), + box_pointer(data_closure as *const u8), + ); + let _ = js_node_stream_method_pipe( + raw_ptr_from_value(source) as i64, + destination, + f64::from_bits(TAG_UNDEFINED), + ); + + let source_handle = raw_ptr_from_value(source) as i64; + let _ = js_node_stream_method_write( + source_handle, + string_value("a"), + f64::from_bits(TAG_UNDEFINED), + f64::from_bits(TAG_UNDEFINED), + ); + let _ = js_node_stream_method_end(source_handle, f64::from_bits(TAG_UNDEFINED)); + let _ = crate::promise::js_promise_run_microtasks(); + + READABLE_DATA_CAPTURED.with(|captured| { + assert_eq!( + captured.borrow().as_slice(), + &[b"a".to_vec(), b"!".to_vec()] + ); + }); + TRANSFORM_FLUSH_COUNT.with(|count| assert_eq!(*count.borrow(), 1)); + TRANSFORM_THIS_HAS_STREAM_STATE.with(|matches| { + assert_eq!(matches.borrow().as_slice(), &[true]); + }); +} + #[test] fn transform_callback_can_push_multiple_outputs_per_input() { READABLE_DATA_CAPTURED.with(|captured| captured.borrow_mut().clear()); diff --git a/test-files/test_gap_10450_transform_pipe_flush.ts b/test-files/test_gap_10450_transform_pipe_flush.ts new file mode 100644 index 0000000000..2e99e4ad15 --- /dev/null +++ b/test-files/test_gap_10450_transform_pipe_flush.ts @@ -0,0 +1,48 @@ +import { PassThrough, Transform } from "stream"; + +function collect(label: string, stream: Transform): void { + let output = ""; + stream.on("data", (chunk) => { + output += chunk; + }); + stream.on("end", () => { + console.log(label, JSON.stringify(output)); + }); +} + +const direct = new Transform({ + transform(chunk, _encoding, callback) { + callback(null, String(chunk).toUpperCase()); + }, + flush(callback) { + callback(null, "|flushed"); + }, +}); +collect("direct", direct); +direct.write("ab"); +direct.end("cd"); + +const source = new PassThrough(); +const piped = source.pipe(new Transform({ + transform(chunk, _encoding, callback) { + callback(null, String(chunk).toUpperCase()); + }, + flush(callback) { + callback(null, "|flushed"); + }, +})); +collect("piped", piped); +source.write("ab"); +source.end("cd"); + +const asyncSource = new PassThrough(); +const asyncPiped = asyncSource.pipe(new Transform({ + transform(chunk, _encoding, callback) { + callback(null, String(chunk).toUpperCase()); + }, + flush(callback) { + queueMicrotask(() => callback(null, "|async-flushed")); + }, +})); +collect("async piped", asyncPiped); +asyncSource.end("ef"); From 8c0e40959058d0b17c96774af71314f9830e5cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 22:22:02 +0200 Subject: [PATCH 31/32] docs(changelog): note piped transform flush fix --- changelog.d/transform-pipe-flush.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/transform-pipe-flush.md diff --git a/changelog.d/transform-pipe-flush.md b/changelog.d/transform-pipe-flush.md new file mode 100644 index 0000000000..fcd0481514 --- /dev/null +++ b/changelog.d/transform-pipe-flush.md @@ -0,0 +1,6 @@ +### Fixed + +- **Piped `Transform` streams now emit their flush output before `end`.** Pipe + completion runs `_flush` or the `flush` option before closing the readable + side, including when the flush callback completes asynchronously. Fixes + #10450 in #11037. From e18655633a41d4940a25b904f312299b0e65b78a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 01:53:04 +0200 Subject: [PATCH 32/32] chore: release merge train 257 as v0.5.1640 --- CLAUDE.md | 2 +- Cargo.lock | 132 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 68 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8bac1a025..dd36ff0f47 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.1639 +**Current Version:** 0.5.1640 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 9180f15d5d..0814134c3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,7 +5447,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "base64 0.22.1", @@ -5508,7 +5508,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-dispatch", "serde", @@ -5516,7 +5516,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "cc", "libc", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "aho-corasick", "anyhow", @@ -5542,7 +5542,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-hir", @@ -5550,7 +5550,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-hir", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-dispatch", @@ -5567,7 +5567,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-hir", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "base64 0.22.1", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-hir", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "async-trait", "clap", @@ -5619,14 +5619,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", ] [[package]] name = "perry-db-turnloop" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "perry-tls-turnloop", @@ -5634,7 +5634,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "serde", "serde_json", @@ -5642,7 +5642,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1639" +version = "0.5.1640" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5653,7 +5653,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "clap", @@ -5668,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "block2", "objc2", @@ -5678,7 +5678,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "argon2", "perry-ffi", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "bcrypt", "perry-ffi", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "rusqlite", @@ -5703,7 +5703,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "scraper", @@ -5711,7 +5711,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "rust_decimal", @@ -5719,7 +5719,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "perry-runtime", @@ -5735,7 +5735,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "bytes", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "lazy_static", "perry-db-turnloop", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "bson", "futures-util", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "bytes", "perry-ffi", @@ -5811,7 +5811,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "turnloop-smtp", @@ -5820,7 +5820,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "notify", "perry-ffi", @@ -5832,7 +5832,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "printpdf", @@ -5840,7 +5840,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "fast_image_resize", "image", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "lazy_static", "perry-ffi", @@ -5860,7 +5860,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-ffi", @@ -5880,7 +5880,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "perry-runtime", @@ -5889,7 +5889,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "lazy_static", "perry-ffi", @@ -5904,7 +5904,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "brotli", "flate2", @@ -5914,7 +5914,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -5924,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-api-manifest", @@ -5944,7 +5944,7 @@ dependencies = [ [[package]] name = "perry-http-client" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "perry-tls-session", @@ -5957,7 +5957,7 @@ dependencies = [ [[package]] name = "perry-http-server" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "http", "httpdate", @@ -5967,11 +5967,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1639" +version = "0.5.1640" [[package]] name = "perry-parser" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "perry-diagnostics", @@ -5984,7 +5984,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perex", "regex", @@ -5992,7 +5992,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "ahash", "base64 0.22.1", @@ -6051,14 +6051,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6140,21 +6140,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-tls-session" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "turnloop-tls", ] [[package]] name = "perry-tls-turnloop" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-ffi", "perry-tls-session", @@ -6163,14 +6163,14 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "dirs", "perry-ffi", @@ -6180,7 +6180,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "jni", @@ -6195,7 +6195,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "rand 0.10.2", "serde", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "async-channel", "async-executor", @@ -6230,7 +6230,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "block2", @@ -6247,7 +6247,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "block2", @@ -6264,7 +6264,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1639" +version = "0.5.1640" [[package]] name = "perry-ui-test" @@ -6275,11 +6275,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1639" +version = "0.5.1640" [[package]] name = "perry-ui-tvos" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "block2", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "block2", @@ -6313,7 +6313,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "block2", "libc", @@ -6327,7 +6327,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "libc", @@ -6346,7 +6346,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "base64 0.22.1", "libc", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "anyhow", "base64 0.22.1", @@ -6374,7 +6374,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1639" +version = "0.5.1640" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 4e073838e2..f7ed172df0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -320,7 +320,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1639" +version = "0.5.1640" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"