diff --git a/CLAUDE.md b/CLAUDE.md index b434e7c262..7bec8e9cd0 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.1551 +**Current Version:** 0.5.1552 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index ae90e9b1d7..c55de3dccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5684,13 +5684,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db" +checksum = "060b4682849d20ebcba05d68f9584a1bba20af4b7838c688cfb37af572f562f4" [[package]] name = "perry" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "base64 0.22.1", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-dispatch", "serde", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "cc", "libc", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "aho-corasick", "anyhow", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-hir", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-hir", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-dispatch", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-hir", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "base64 0.22.1", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-hir", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "async-trait", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "serde", "serde_json", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1551" +version = "0.5.1552" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "clap", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "block2", "objc2", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "argon2", "perry-ffi", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "reqwest", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "bcrypt", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "rusqlite", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "scraper", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "perry-runtime", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "chrono", "cron", @@ -5981,7 +5981,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "chrono", "perry-ffi", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "rust_decimal", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "serde_json", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "perry-runtime", @@ -6021,14 +6021,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "bytes", "http-body-util", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "bytes", "lazy_static", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "bytes", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "lazy_static", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "lru", "perry-ffi", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "chrono", "perry-ffi", @@ -6129,7 +6129,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "bson", "futures-util", @@ -6141,7 +6141,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "chrono", "perry-ffi", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "nanoid", "perry-ffi", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "bytes", "perry-ffi", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "lettre", "perry-ffi", @@ -6206,7 +6206,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "notify", "perry-ffi", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "printpdf", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "sqlx", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "perry-runtime", @@ -6244,7 +6244,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "governor", "perry-ffi", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "fast_image_resize", "image", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "lazy_static", "perry-ffi", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-ffi", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "perry-runtime", @@ -6301,7 +6301,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "uuid", @@ -6309,7 +6309,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "perry-validation", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "futures-util", "lazy_static", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "brotli", "flate2", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6351,7 +6351,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-api-manifest", @@ -6372,11 +6372,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1551" +version = "0.5.1552" [[package]] name = "perry-parser" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-diagnostics", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perex", "regex", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "ahash", "anyhow", @@ -6458,14 +6458,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1551" +version = "0.5.1552" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1551" +version = "0.5.1552" [[package]] name = "perry-ui-tvos" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "idna", "regex", @@ -6790,7 +6790,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1551" +version = "0.5.1552" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index b3a2237c9a..81fc773085 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1551" +version = "0.5.1552" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" @@ -399,7 +399,7 @@ chrono = "0.4" regex = "1.12" aho-corasick = "1.1" # The single regular-expression engine, through crates/perry-perex. -perex = "0.1.2" +perex = "0.1.3" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/changelog.d/10183-regex-cross-call-rebinding.md b/changelog.d/10183-regex-cross-call-rebinding.md new file mode 100644 index 0000000000..ad30bdc592 --- /dev/null +++ b/changelog.d/10183-regex-cross-call-rebinding.md @@ -0,0 +1,3 @@ +### Performance + +- **A RegExp search per JavaScript call binds its program and subject in constant work** (#10166). JS-level `exec` and `test` loops, `matchAll` iteration and `search` used to decode the whole string and revalidate the whole program on every call, so a loop over one string did quadratic work and each `.test()` paid a full program validation. A compiled program now keeps a small witness of its validation, and a string remembers that its bytes validated, both as plain data with no extra garbage-collector work. Non-ASCII subjects still seek from the nearer end once per call. Requires `perex` 0.1.3. diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index a5e4bf025f..627516524d 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -19,6 +19,8 @@ mod old_defrag_contract; #[cfg(feature = "regex-engine")] mod perex_construction; #[cfg(feature = "regex-engine")] +mod perex_cross_call; +#[cfg(feature = "regex-engine")] mod perex_dispatch; #[cfg(feature = "regex-engine")] mod perex_execution; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_cross_call.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_cross_call.rs new file mode 100644 index 0000000000..4f8811386e --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_cross_call.rs @@ -0,0 +1,227 @@ +//! Cross-call rebinding (#10166): a program cell keeps a plain-data witness of +//! its validated words, and a string header remembers that its payload +//! validated, so a search per JavaScript call binds both in constant work. +use super::*; +use crate::regex::perex_api as api; +use crate::regex::perex_owner::{cell_witness, set_cell_witness, GcProgram}; +use crate::regex::perex_runtime::EngineError; +use crate::regex::RegExpHeader; +use crate::string::{StringHeader, STRING_FLAG_WTF8_VALIDATED}; +use crate::value::js_nanbox_string; +use perex::binding::ImmutableProgram; + +fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { + scope.root_string_ptr(crate::string::js_string_from_bytes( + bytes.as_ptr(), + bytes.len() as u32, + )) +} + +fn regex<'s>(scope: &'s RuntimeHandleScope, pattern: &str) -> RuntimeHandle<'s> { + let pattern = text(scope, pattern.as_bytes()); + let flags = text(scope, b""); + scope.root_raw_mut_ptr(pattern.with_const_ptr::(|pattern| { + flags.with_const_ptr::(|flags| crate::regex::js_regexp_new(pattern, flags)) + })) +} + +/// One non-global search: the full match's UTF-16 span, or None. +fn search( + re: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, +) -> Result, EngineError> { + re.with_mut_ptr::(|re| { + input.with_const_ptr::(|input| { + api::execute(re, input, false, &mut || Ok(())) + .map(|found| found.map(|m| (m.full.start(), m.full.end()))) + }) + }) +} + +/// The witness in the RegExp's current program cell. +fn witness(re: &RuntimeHandle<'_>) -> Option { + re.with_const_ptr::(|re| unsafe { cell_witness((*re).perex_program) }) +} + +fn set_witness(re: &RuntimeHandle<'_>, value: Option) { + re.with_const_ptr::(|re| unsafe { + set_cell_witness((*re).perex_program, value) + }); +} + +fn recompile(scope: &RuntimeHandleScope, re: &RuntimeHandle<'_>, pattern: &str) { + let pattern = text(scope, pattern.as_bytes()); + let flags = text(scope, b""); + let p = pattern.with_const_ptr::(|p| js_nanbox_string(p as i64)); + let f = flags.with_const_ptr::(|f| js_nanbox_string(f as i64)); + re.with_mut_ptr::(|re| crate::regex::js_regexp_compile_value(re, p, f)); +} + +fn validated(s: &RuntimeHandle<'_>) -> bool { + s.with_const_ptr::(|s| unsafe { (*s).flags & STRING_FLAG_WTF8_VALIDATED != 0 }) +} + +#[test] +fn perex_program_witness_lives_with_its_cell_and_a_recompile_starts_without_one() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let input = text(&scope, b"xb xa"); + + let re = regex(&scope, "x(a)"); + assert_eq!( + witness(&re), + None, + "a freshly compiled program has no witness" + ); + assert_eq!(search(&re, &input).unwrap(), Some((3, 5))); + let wa = witness(&re).expect("the first validating bind records the witness"); + + recompile(&scope, &re, "x(b)"); + assert_eq!( + witness(&re), + None, + "a recompile emits a new cell, which starts without a witness" + ); + assert_eq!(search(&re, &input).unwrap(), Some((0, 2))); + let wb = witness(&re).expect("the new cell records its own witness"); + + // Precondition for the next step: these programs share length and header. + assert_eq!( + wa, wb, + "precondition: x(a) and x(b) must share length and header" + ); + // Even a foreign witness over an equal header binds the cell's CURRENT + // words, which Perex emitted: the answer is this program's. + recompile(&scope, &re, "x(a)"); + set_witness(&re, Some(wb)); + assert_eq!(search(&re, &input).unwrap(), Some((3, 5))); +} + +#[test] +fn perex_program_witness_that_does_not_match_falls_back_to_validation() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let input = text(&scope, b"ba"); + let a = regex(&scope, "a"); + let b = regex(&scope, "b"); + assert_eq!(search(&a, &input).unwrap(), Some((1, 2))); + assert_eq!(search(&b, &input).unwrap(), Some((0, 1))); + let (wa, wb) = (witness(&a).unwrap(), witness(&b).unwrap()); + assert_ne!( + wa, wb, + "precondition: a and b must differ in length or header" + ); + + set_witness(&a, Some(wb)); + assert_eq!(search(&a, &input).unwrap(), Some((1, 2))); + assert_eq!( + witness(&a), + Some(wa), + "a mismatched witness is replaced by validation" + ); +} + +#[test] +fn perex_subject_is_marked_only_after_it_validates_with_its_exact_length() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let digits = regex(&scope, r"\d+"); + + let good = text(&scope, "ä1 b22 c333 longer than inline".as_bytes()); + assert!(!validated(&good)); + assert_eq!(search(&digits, &good).unwrap(), Some((1, 2))); + assert!( + validated(&good), + "a valid payload with an exact length is marked" + ); + assert_eq!( + search(&digits, &good).unwrap(), + Some((1, 2)), + "the counted bind answers the same" + ); + + let wrong_length = text(&scope, b"abcdef1 and longer than inline"); + wrong_length.with_const_ptr::(|s| unsafe { + (*(s as *mut StringHeader)).utf16_len = 3; + }); + let _ = search(&digits, &wrong_length); + assert!( + !validated(&wrong_length), + "a header whose length disagrees with its payload must never be trusted" + ); + + let malformed = text(&scope, b"\xff\xfe 1 malformed and longer than inline"); + let result = search(&digits, &malformed); + assert!( + result.is_err(), + "malformed WTF-8 is rejected as before: {result:?}" + ); + assert!( + !validated(&malformed), + "a payload that failed to validate is never marked" + ); +} + +#[test] +fn perex_cross_call_marks_survive_moving_collections() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, r"\d+"); + let input = text( + &scope, + "ö7 and some text after it, beyond inline".as_bytes(), + ); + assert_eq!(search(&re, &input).unwrap(), Some((1, 2))); + let (w, marked) = (witness(&re), validated(&input)); + assert!(w.is_some() && marked); + let (re_before, input_before) = ( + re.with_const_ptr::(|p| p as usize), + input.with_const_ptr::(|p| p as usize), + ); + let cycles = copying_minor_cycles(); + gc_collect_minor(); + gc_collect_minor(); + assert!(copying_minor_cycles() > cycles); + assert_ne!( + input.with_const_ptr::(|p| p as usize), + input_before, + "the string must move" + ); + assert_ne!( + re.with_const_ptr::(|p| p as usize), + re_before, + "the RegExp must move" + ); + assert_eq!(witness(&re), w, "the witness moves with its RegExp"); + assert!(validated(&input), "the mark moves with its string"); + assert_eq!(search(&re, &input).unwrap(), Some((1, 2))); +} + +/// Debug builds prove a witness is never written under a live view of the +/// words it describes (#10166). +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "must not be written while a view of its words is live")] +fn perex_program_witness_write_under_a_live_view_is_caught() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, "q+"); + let input = text(&scope, b"qq"); + assert_eq!(search(&re, &input).unwrap(), Some((0, 2))); + let witness = witness(&re).expect("validated"); + let owner = unsafe { GcProgram::from_receiver(&scope, &re) }.unwrap(); + let root = owner.root(); + let _ = owner.with_words(|_words| GcProgram::record_witness(&root, witness)); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs index e6fdd12f94..3eddd46247 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -140,15 +140,17 @@ fn perex_reuse_serves_a_whole_global_loop_across_moving_collections() { let fresh = regex(&scope, PATTERN, "gu"); let (fresh_matches, fresh_work) = global_loop(&fresh, &fresh_input, None); assert_eq!(fresh_matches, expected); - // Six searches (five matches and the final miss). Binding per search charges - // program validation six times; reuse charged it once, in `setup`. Reuse - // also resumes each search from the previous one instead of seeking from - // an end of this non-ASCII subject, so it saves at least the validations. + // Both loops validate their program once: the witness in each program cell + // lets later searches bind it without validating (#10166). The reused loop + // paid its validation in `setup`, and additionally resumes each search from + // the previous one instead of seeking from an end of this non-ASCII subject, + // so it must charge strictly less than the fresh loop minus one validation. + // Without reuse the two differ by exactly that one validation. let validation = api::WORK - setup.remaining(); assert!(validation > 0); assert!( - fresh_work >= reused_work + 6 * validation, - "reuse must save at least six program validations: fresh {fresh_work}, reused {reused_work}, one validation {validation}" + fresh_work > reused_work + validation, + "reuse must save its seeks as well as the validation: fresh {fresh_work}, reused {reused_work}, one validation {validation}" ); } diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index d467743e99..2175921db9 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -106,7 +106,72 @@ pub(crate) fn program<'s>( ) -> Result>, EngineError> { let owner = unsafe { GcProgram::from_receiver(scope, receiver) } .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?; - BoundProgram::new(owner, budget).map_err(|e| EngineError::Program(e.error)) + bind_program(owner, budget) +} + +/// Bind a program, in constant work when a binding validated this same cell +/// before (#10166): the witness lives beside the words in the program cell, so +/// it cannot describe other words. A witness that does not match falls back to +/// validation, which records a fresh one. No allocation and nothing traced. +pub(crate) fn bind_program<'s>( + owner: GcProgram<'s>, + budget: &mut Budget, +) -> Result>, EngineError> { + let root = owner.root(); + let owner = match owner.witness() { + Some(witness) => match BoundProgram::new_witnessed(owner, witness) { + Ok(bound) => return Ok(bound), + Err(failed) => failed.storage, + }, + None => owner, + }; + let bound = BoundProgram::new(owner, budget).map_err(|e| EngineError::Program(e.error))?; + GcProgram::record_witness(&root, bound.witness()); + Ok(bound) +} + +/// Bind a whole heap string, in constant work when this header was validated +/// before (#10166). The first binding decodes it; if that succeeds and its +/// UTF-16 length matches the header, the header is marked +/// `STRING_FLAG_WTF8_VALIDATED` and later bindings use `new_counted`. +/// +/// Perry strings are not all valid WTF-8 (raw Buffer and FFI payloads reach +/// here too), so validity is never assumed: a string that fails to validate is +/// never marked and keeps failing exactly as before. `HeapSubject::new` has +/// already marked the header shared, so a marked payload is never mutated in +/// place, and the mark is never copied to another string (see the flag). +pub(crate) fn bind_heap_subject( + input: RuntimeHandle<'_>, +) -> Result>, EngineError> { + use crate::string::STRING_FLAG_WTF8_VALIDATED; + let (utf16_len, validated) = input.with_const_ptr::(|s| unsafe { + ( + (*s).utf16_len as usize, + (*s).flags & STRING_FLAG_WTF8_VALIDATED != 0, + ) + }); + let owner = unsafe { HeapSubject::new(input) } + .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?; + let owner = if validated { + match BoundSubject::new_counted(owner, utf16_len) { + Ok(bound) => return Ok(bound), + Err(failed) => failed.storage, + } + } else { + owner + }; + let bound = BoundSubject::new(owner).map_err(|e| EngineError::Subject(e.error))?; + let decoded = bound + .with_view(|view| view.len_utf16()) + .map_err(EngineError::Subject)?; + // An empty string has nothing to decode. `HeapSubject::new` already wrote + // this header's refcount, so it is writable. + if decoded == utf16_len && utf16_len > 0 { + input.with_const_ptr::(|s| unsafe { + (*(s as *mut StringHeader)).flags |= STRING_FLAG_WTF8_VALIDATED; + }); + } + Ok(bound) } /// Bindings one compound operation reuses across its searches (#10165). @@ -162,7 +227,7 @@ impl<'b, 's> Reuse<'b, 's> { let receiver = scope.root_raw_const_ptr(re); let cell = scope.root_raw_const_ptr(unsafe { (*re).perex_program }); let owner = unsafe { GcProgram::from_receiver(scope, &receiver) }.ok()?; - let bound = BoundProgram::new(owner, budget).ok()?; + let bound = bind_program(owner, budget).ok()?; Some(ReusedProgram { receiver, cell, @@ -339,11 +404,7 @@ pub(crate) fn execute_with_resources( let subject = match reused_subject { Some(subject) => subject, None => { - fresh_subject = - BoundSubject::new(unsafe { HeapSubject::new(input) }.map_err(|e| { - EngineError::Subject(perex::binding::SubjectError::Resource(e)) - })?) - .map_err(|e| EngineError::Subject(e.error))?; + fresh_subject = bind_heap_subject(input)?; &fresh_subject } }; diff --git a/crates/perry-runtime/src/regex/perex_match_search.rs b/crates/perry-runtime/src/regex/perex_match_search.rs index c09a371025..3222675382 100644 --- a/crates/perry-runtime/src/regex/perex_match_search.rs +++ b/crates/perry-runtime/src/regex/perex_match_search.rs @@ -53,11 +53,7 @@ pub(crate) fn flags(receiver: f64) -> Result<*mut StringHeader, EngineError> { pub(super) fn subject( input: RuntimeHandle<'_>, ) -> Result>, EngineError> { - BoundSubject::new( - unsafe { HeapSubject::new(input) } - .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?, - ) - .map_err(|e| EngineError::Subject(e.error)) + api::bind_heap_subject(input) } fn match_flags( diff --git a/crates/perry-runtime/src/regex/perex_owner.rs b/crates/perry-runtime/src/regex/perex_owner.rs index 5606175edc..05a7188fe3 100644 --- a/crates/perry-runtime/src/regex/perex_owner.rs +++ b/crates/perry-runtime/src/regex/perex_owner.rs @@ -13,6 +13,12 @@ use perex::compiler::{CompileError, Prepared}; #[repr(C)] struct ProgramCell { word_count: usize, + /// What validating these words established, so later bindings of this same + /// cell skip validation (#10166). Plain data beside the words it describes: + /// the words never change, and a recompile emits a new cell that starts + /// with none, so it cannot describe other words. Stored by the first + /// validating bind; the cell stays a pointer-free leaf. + witness: Option, // Immediately followed by word_count initialized u32 words. } @@ -74,7 +80,10 @@ impl<'scope> GcProgram<'scope> { // finalizer or a leaked external owner. No GC call occurs in this scope. unsafe { // GC_STORE_AUDIT(POINTER_FREE): the program cell is a leaf of u32 words; its prefix is a count. - cell.write(ProgramCell { word_count: words }); + cell.write(ProgramCell { + word_count: words, + witness: None, + }); let output = cell.add(1).cast::(); output.write_bytes(0, words); let output = std::slice::from_raw_parts_mut(output, words); @@ -106,6 +115,39 @@ impl<'scope> GcProgram<'scope> { }); } + /// The witness stored beside this program's words, if a binding validated + /// them before (#10166). + pub(crate) fn witness(&self) -> Option { + self.root + .with_const_ptr::(|cell| unsafe { (*cell).witness }) + } + + /// Record what validating this program established. `witness` must come + /// from a binding of this same cell. + pub(crate) fn record_witness( + root: &RuntimeHandle<'_>, + witness: perex::binding::ProgramWitness, + ) { + // The prefix lies outside the word slice, but the write still goes + // through the cell's own pointer and never under a live view of its + // words (a binding holds none between calls). + #[cfg(debug_assertions)] + debug_assert_eq!( + PROGRAM_VIEWS.with(std::cell::Cell::get), + 0, + "a program cell's witness must not be written while a view of its words is live" + ); + // A plain-data store into a pointer-free leaf: no allocation, no barrier. + root.with_const_ptr::(|cell| unsafe { + (*(cell as *mut ProgramCell)).witness = Some(witness); + }); + } + + /// This program's registered root, which survives consuming the owner. + pub(crate) fn root(&self) -> RuntimeHandle<'scope> { + self.root + } + /// Establish a separate operation root, so reentrant receiver recompilation /// cannot replace the immutable program of an already-running operation. /// @@ -127,6 +169,46 @@ impl<'scope> GcProgram<'scope> { } } +/// The witness stored in the program cell at `program` (a RegExp's +/// `perex_program`), for tests. +#[cfg(test)] +pub(crate) unsafe fn cell_witness(program: *const u8) -> Option { + unsafe { (*(program as *const ProgramCell)).witness } +} + +/// Overwrite the witness stored in the program cell at `program`, for tests. +#[cfg(test)] +pub(crate) unsafe fn set_cell_witness( + program: *const u8, + witness: Option, +) { + unsafe { (*(program as *mut ProgramCell)).witness = witness }; +} + +// How many `with_words` views of any program cell are live on this thread, so +// debug builds can prove a witness is never written under one (#10166). +#[cfg(debug_assertions)] +thread_local! { + static PROGRAM_VIEWS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +struct ProgramView; + +impl ProgramView { + fn open() -> Self { + #[cfg(debug_assertions)] + PROGRAM_VIEWS.with(|views| views.set(views.get() + 1)); + ProgramView + } +} + +impl Drop for ProgramView { + fn drop(&mut self) { + #[cfg(debug_assertions)] + PROGRAM_VIEWS.with(|views| views.set(views.get() - 1)); + } +} + impl ImmutableProgram for GcProgram<'_> { type Error = OwnerError; @@ -149,6 +231,7 @@ impl ImmutableProgram for GcProgram<'_> { // Only emit creates these cells; no mutable word access escapes. // Binding validation is separate, once per immutable owner. This // getter neither allocates nor polls and always reacquires the base. + let _view = ProgramView::open(); Ok(f(std::slice::from_raw_parts(cell.add(1).cast(), count))) }) } diff --git a/crates/perry-runtime/src/string/append.rs b/crates/perry-runtime/src/string/append.rs index 789f3dff1b..5c51849197 100644 --- a/crates/perry-runtime/src/string/append.rs +++ b/crates/perry-runtime/src/string/append.rs @@ -127,7 +127,7 @@ pub extern "C" fn js_string_append( ); (*dest).byte_len = new_blen; (*dest).utf16_len += (*src).utf16_len; - (*dest).flags |= flag_bits; + (*dest).flags = ((*dest).flags | flag_bits) & !STRING_FLAG_WTF8_VALIDATED; return if boundary_pair { // Merge the straddling pair (usually returns a new, smaller // string; rare, so the in-place win still holds in general). diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index 800d942489..9fae0db487 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -806,7 +806,7 @@ pub extern "C" fn js_string_concat_value( let memoizable = total_blen <= CONCAT_MEMO_MAX_BYTES as usize && is_valid_string_ptr(prefix) && prefix_u16 == prefix_blen - && unsafe { (*prefix).flags == 0 } + && unsafe { (*prefix).flags & !STRING_FLAG_WTF8_VALIDATED == 0 } && bytes_all_ascii(string_data(prefix), prefix_blen) && concat_memo_should_probe(); let mut memo_buf = [0u8; CONCAT_MEMO_MAX_BYTES as usize]; @@ -1073,7 +1073,8 @@ fn append_chain_all_heap_strings( } (*dest).byte_len = total_blen; (*dest).utf16_len = total_u16; - (*dest).flags |= piece_flags; + // The destination's payload just changed; no piece's validation carries over. + (*dest).flags = ((*dest).flags | piece_flags) & !STRING_FLAG_WTF8_VALIDATED; return if piece_flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 { canonicalize_surrogate_pairs(dest) } else { diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 24fb6f64e4..fceb76cd36 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -135,6 +135,8 @@ mod trim_tests; /// end of an exact-sized payload. Unix-only (needs `mmap` + `mprotect`). #[cfg(all(test, unix))] mod tests_guard_page; +#[cfg(test)] +mod tests_validated_flag; // Explicit named re-exports — preserve the original `crate::string::*` // surface 1:1. NO glob re-exports. @@ -262,6 +264,17 @@ pub const STRING_FLAG_HAS_LONE_SURROGATES: u32 = 1; /// byte-level escape scan. String-producing mutations do not propagate this /// provenance bit unless they independently prove the resulting payload. pub(crate) const STRING_FLAG_JSON_ESCAPE_FREE: u32 = 1 << 1; +/// This exact header's payload was fully validated as generalized WTF-8 and its +/// `utf16_len` found exact, so a RegExp can bind it again in constant work +/// (`perex::binding::BoundSubject::new_counted`) instead of decoding it (#10166). +/// +/// Only the regex subject binding sets it, after a full validation succeeds. It +/// describes one payload, so it must never reach another string: +/// `init_string_header` strips it from every constructed string, and the +/// in-place writers (`js_string_append`, `js_string_append_chain`) clear it on +/// the destination they change. A validated header is already shared +/// (`js_string_addref`), so it is never itself mutated in place afterwards. +pub(crate) const STRING_FLAG_WTF8_VALIDATED: u32 = 1 << 2; /// A static empty string that can be used as a safe fallback for null pointers. /// Has utf16_len=0, byte_len=0, capacity=0, refcount=0, flags=0 (shared). @@ -812,7 +825,9 @@ pub(crate) unsafe fn init_string_header( (*ptr).byte_len = byte_len; (*ptr).capacity = capacity; (*ptr).refcount = refcount; - (*ptr).flags = flags; + // A new header never inherits its source's validation (#10166), however a + // caller computed `flags`. + (*ptr).flags = flags & !STRING_FLAG_WTF8_VALIDATED; } #[inline] diff --git a/crates/perry-runtime/src/string/tests_validated_flag.rs b/crates/perry-runtime/src/string/tests_validated_flag.rs new file mode 100644 index 0000000000..aa91667edc --- /dev/null +++ b/crates/perry-runtime/src/string/tests_validated_flag.rs @@ -0,0 +1,101 @@ +//! `STRING_FLAG_WTF8_VALIDATED` describes one header's payload (#10166). A +//! RegExp trusts it to bind in constant work without decoding, so it must never +//! reach any other string: a wrong bit gives wrong answers or a panic. +use super::*; + +fn heap(text: &str) -> *mut StringHeader { + js_string_from_bytes(text.as_ptr(), text.len() as u32) +} + +/// A shared heap string marked the way the regex binding marks one. +fn validated(text: &str) -> *mut StringHeader { + let s = heap(text); + unsafe { + (*s).refcount = 0; + (*s).flags |= STRING_FLAG_WTF8_VALIDATED; + } + s +} + +fn carries(s: *const StringHeader) -> bool { + unsafe { (*s).flags & STRING_FLAG_WTF8_VALIDATED != 0 } +} + +fn assert_not_inherited(name: &str, source: *const StringHeader, derived: *const StringHeader) { + if derived != source { + assert!( + !carries(derived), + "{name} copied the validation of its source" + ); + } +} + +#[test] +fn string_validated_flag_is_never_inherited_by_a_derived_string() { + let v = validated("abcdef"); + let w = validated("xyz"); + assert!(carries(v) && carries(w)); + assert_not_inherited("slice", v, js_string_slice(v, 1, 4)); + assert_not_inherited("substring", v, js_string_substring(v, 0, 2)); + assert_not_inherited("trim", v, js_string_trim(validated(" abc "))); + assert_not_inherited("concat", v, js_string_concat(v, w)); + assert_not_inherited("repeat", v, js_string_repeat(v, 3.0)); + assert_not_inherited("padStart", v, js_string_pad_start(v, 12.0, w)); + assert_not_inherited("toUpperCase", v, crate::string::js_string_to_upper_case(v)); + let array = crate::array::js_array_alloc(2); + let array = crate::array::js_array_push_f64(array, crate::value::js_nanbox_string(v as i64)); + let array = crate::array::js_array_push_f64(array, crate::value::js_nanbox_string(w as i64)); + assert_not_inherited( + "join", + v, + crate::array::js_array_join(array, validated(",")), + ); + // Constructors that take a caller-computed flags word, passed the source's + // whole word on purpose: the funnel must still strip the validation. + let whole = unsafe { (*v).flags }; + assert_not_inherited("string_copy_range", v, string_copy_range(v, 0, 3, 3, whole)); + assert_not_inherited( + "js_string_from_bytes_known_utf16", + v, + js_string_from_bytes_known_utf16(b"abc".as_ptr(), 3, 3, whole), + ); +} + +#[test] +fn string_in_place_append_clears_the_destinations_validation() { + // A unique destination with spare capacity is appended in place. It cannot + // normally be validated (validation happens on shared strings); mark it + // anyway to prove the writers clear the bit when the payload changes. + let dest = js_string_from_bytes_with_capacity(b"ab".as_ptr(), 2, 64); + let piece = validated("cd"); + unsafe { + (*dest).refcount = 1; + (*dest).flags |= STRING_FLAG_WTF8_VALIDATED; + } + let appended = js_string_append(dest, piece); + assert_eq!(appended, dest, "the test needs the in-place path"); + assert!( + !carries(appended), + "js_string_append kept a stale validation" + ); + + let chain_dest = js_string_from_bytes_with_capacity(b"ab".as_ptr(), 2, 64); + unsafe { + (*chain_dest).refcount = 1; + (*chain_dest).flags |= STRING_FLAG_WTF8_VALIDATED; + } + let parts = [ + crate::value::js_nanbox_string(chain_dest as i64), + crate::value::js_nanbox_string(validated("cd") as i64), + crate::value::js_nanbox_string(validated("ef") as i64), + ]; + let chained = js_string_append_chain(parts.as_ptr(), 3); + assert_eq!( + chained, chain_dest, + "the test needs the in-place chain path" + ); + assert!( + !carries(chained), + "js_string_append_chain kept a stale validation" + ); +}