diff --git a/CLAUDE.md b/CLAUDE.md index 2a074af4fa..d29ed8825d 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.1557 +**Current Version:** 0.5.1558 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e91294a5ab..f40f875f4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5690,7 +5690,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "base64 0.22.1", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-dispatch", "serde", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "cc", "libc", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "aho-corasick", "anyhow", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-hir", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-hir", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-dispatch", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-hir", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "base64 0.22.1", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-hir", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "async-trait", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "serde", "serde_json", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1557" +version = "0.5.1558" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "clap", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "block2", "objc2", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "argon2", "perry-ffi", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "reqwest", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "bcrypt", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "rusqlite", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "scraper", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "perry-runtime", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "chrono", "cron", @@ -5981,7 +5981,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "chrono", "perry-ffi", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "rust_decimal", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "serde_json", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "perry-runtime", @@ -6021,14 +6021,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "bytes", "http-body-util", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "bytes", "lazy_static", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "bytes", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "lazy_static", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "lru", "perry-ffi", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "chrono", "perry-ffi", @@ -6129,7 +6129,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "bson", "futures-util", @@ -6141,7 +6141,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "chrono", "perry-ffi", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "nanoid", "perry-ffi", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "bytes", "perry-ffi", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "lettre", "perry-ffi", @@ -6206,7 +6206,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "notify", "perry-ffi", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "printpdf", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "sqlx", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "perry-runtime", @@ -6244,7 +6244,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "governor", "perry-ffi", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "fast_image_resize", "image", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "lazy_static", "perry-ffi", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-ffi", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "perry-runtime", @@ -6301,7 +6301,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "uuid", @@ -6309,7 +6309,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "perry-validation", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "futures-util", "lazy_static", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "brotli", "flate2", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6351,7 +6351,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-api-manifest", @@ -6372,11 +6372,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1557" +version = "0.5.1558" [[package]] name = "perry-parser" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-diagnostics", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perex", "regex", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "ahash", "anyhow", @@ -6458,14 +6458,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1557" +version = "0.5.1558" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1557" +version = "0.5.1558" [[package]] name = "perry-ui-tvos" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "idna", "regex", @@ -6790,7 +6790,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1557" +version = "0.5.1558" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 9b3dfc8fa6..33d27e8f9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1557" +version = "0.5.1558" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10212-regex-per-call.md b/changelog.d/10212-regex-per-call.md new file mode 100644 index 0000000000..0a5b89580f --- /dev/null +++ b/changelog.d/10212-regex-per-call.md @@ -0,0 +1,6 @@ +Made each `RegExp.prototype.test` and `exec` call cheaper. When a RegExp, its +prototype and `exec` are the untouched builtins, the unobservable `exec` +property lookup is skipped. Match scratch for programs of up to 32 registers +and 16 captures is held inline instead of heap-allocated per call, and +captures of an ASCII string are copied as one byte range instead of being +decoded and re-encoded twice. diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs index 2c4438effd..d1988a7019 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs @@ -460,3 +460,73 @@ fn perex_dispatch_proxy_apply_getter_and_nested_trap_survive_movement() { ); } } + +#[test] +fn perex_dispatch_skips_the_exec_lookup_only_when_nothing_can_observe_it() { + // The guard holds the global side-table lock, which the prototype edits + // below need; taking it again here would deadlock. + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let miss = text(&scope, b"x"); + let hit = text(&scope, b"NEVER"); + let lookups = || dispatch::EXEC_LOOKUPS.with(Cell::get); + let yes = function(&scope, return_this as *const u8, 1); + + // Untouched: the builtin runs with no lookup, and still answers. The + // realm records RegExp.prototype's canonical site on first use, so the + // first call on a thread may take the lookup; none after it does. + let plain = regex(&scope); + assert!(!test(&plain, &miss)); + let before = lookups(); + for _ in 0..10 { + assert!(!test(&plain, &miss)); + assert!(test(&plain, &hit)); + } + assert_eq!( + lookups(), + before, + "an untouched RegExp needs no exec lookup" + ); + + // An own exec is found by the lookup, and runs. + let own = regex(&scope); + put(&own, b"exec", &yes); + let before = lookups(); + assert!(test(&own, &miss), "an own exec override must run"); + assert!(lookups() > before); + + // A reparented RegExp resolves exec on its new prototype. + let reparented = regex(&scope); + let parent = object(&scope); + put(&parent, b"exec", &yes); + assert_eq!( + crate::proxy::js_reflect_set_prototype_of( + reparented.get_nanbox_f64(), + parent.get_nanbox_f64() + ) + .to_bits(), + crate::value::TAG_TRUE + ); + let before = lookups(); + assert!( + test(&reparented, &miss), + "the new prototype's exec must run" + ); + assert!(lookups() > before); + + // Replacing RegExp.prototype.exec reaches every RegExp, including a fresh + // one; restoring it restores the skipped lookup. + let proto = scope.root_nanbox_f64(crate::object::builtin_prototype_value("RegExp")); + let original = scope.root_nanbox_f64(api::finish(dispatch::get(&proto, b"exec"))); + put(&proto, b"exec", &yes); + let fresh = regex(&scope); + let before = lookups(); + assert!(test(&fresh, &miss), "a replaced prototype exec must run"); + assert!(lookups() > before); + put(&proto, b"exec", &original); + let before = lookups(); + assert!(!test(&fresh, &miss)); + assert_eq!(lookups(), before); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs index a4f1cf2165..1b9dd2922e 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs @@ -348,3 +348,59 @@ fn perex_public_nonglobal_test_propagates_lastindex_coercion_throw() { assert_eq!(RuntimeHandleScope::active_len_for_tests(), roots); assert_ne!(address::(&input), before); } + +#[test] +fn perex_public_exec_captures_agree_across_inline_and_heap_slots_and_storage() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + register_host_roots(); + let twenty_groups = "(a)".repeat(20); + let twenty_a = "a".repeat(20); + // A few captures fit the inline slots. Twenty groups need 42 registers and + // 21 capture spans, past both inline limits. The alternation backtracks, + // growing frames through a rebuffer. `None` is an unset group. + let cases: [(&str, &str, Vec>); 4] = [ + ("(a)(b)?c", "acz", vec![Some("ac"), Some("a"), None]), + (&twenty_groups, &twenty_a, { + let mut all = vec![Some(twenty_a.as_str())]; + all.extend(std::iter::repeat_n(Some("a"), 20)); + all + }), + ( + "(a|ab)(c|bcd)(d*)", + "abcd", + vec![Some("abcd"), Some("a"), Some("bcd"), Some("")], + ), + ("(x*)$", "abc", vec![Some(""), Some("")]), + ]; + for (pattern, tail, expected) in cases { + // The same match behind an ASCII prefix and a non-ASCII one, so both + // the byte copy and the unit-by-unit copy produce these captures. + for prefix in ["!", "\u{e9}"] { + let scope = RuntimeHandleScope::new(); + let receiver = regex(&scope, pattern, ""); + let subject = format!("{prefix}{tail}"); + let input = text(&scope, subject.as_bytes()); + let result = exec(&receiver, &input); + assert!(!result.is_null(), "/{pattern}/ must match {subject:?}"); + let result = scope.root_raw_mut_ptr(result); + for (index, want) in expected.iter().enumerate() { + let value = item(&result, index as u32); + match want { + None => assert_eq!( + value.to_bits(), + TAG_UNDEFINED, + "/{pattern}/ over {subject:?}: group {index} must be unset" + ), + Some(want) => assert_eq!( + bytes(value), + want.as_bytes(), + "/{pattern}/ over {subject:?}: group {index}" + ), + } + } + } + } +} diff --git a/crates/perry-runtime/src/regex/perex_dispatch.rs b/crates/perry-runtime/src/regex/perex_dispatch.rs index e6f17c5a43..9be7cf0f25 100644 --- a/crates/perry-runtime/src/regex/perex_dispatch.rs +++ b/crates/perry-runtime/src/regex/perex_dispatch.rs @@ -94,10 +94,17 @@ pub(crate) fn call_one( result } +#[cfg(test)] +thread_local! { + /// `Get(R, "exec")` lookups `execute` performed on this thread. + pub(crate) static EXEC_LOOKUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// RegExpExec with operation-owned limits. Lookup happens on every iteration; /// a callback may replace exec or recompile the receiver before the next one. /// Only the known builtin may omit materialization for a boolean test. -/// `reuse` is consulted only on the builtin path, after the observable lookup. +/// `reuse` is consulted only on the builtin path, after the lookup or after +/// proving the lookup would reach the builtin without running anything. pub(crate) fn execute( receiver: &RuntimeHandle<'_>, input: &RuntimeHandle<'_>, @@ -111,7 +118,44 @@ pub(crate) fn execute( require_object(receiver.get_nanbox_f64())?; input.with_mut_ptr::(|input| crate::string::js_string_addref(input)); let scope = RuntimeHandleScope::new(); - let method = scope.root_nanbox_f64(get(receiver, b"exec")?); + // A RegExp whose own properties, prototype and `exec` are the untouched + // builtins reaches the builtin exec without running any code, so the Get + // is unobservable and is skipped. Through the generic property path it was + // about half of every `test` call (#10166). Anything else takes the Get. + let receiver_ptr = + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader; + let known_builtin = super::is_valid_regex_ptr(receiver_ptr) + && crate::object::regex_proto_thunks::regexp_view_uses_builtin(receiver.get_nanbox_f64()); + if !known_builtin { + #[cfg(test)] + EXEC_LOOKUPS.with(|lookups| lookups.set(lookups.get() + 1)); + let method = scope.root_nanbox_f64(get(receiver, b"exec")?); + if let Some(result) = execute_override(&scope, &method, receiver, input)? { + return Ok(result); + } + } + let re = crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader; + if !super::is_valid_regex_ptr(re) { + return Err(EngineError::Type( + "RegExp builtin exec requires a RegExp receiver", + )); + } + // `execute_with_resources` roots both before it allocates. + input + .with_const_ptr::(|input| { + api::execute_with_resources(re, input, materialize, budget, memory, poll, reuse) + }) + .map(|result| result.map(ExecResult::Builtin)) +} + +/// The observable half of RegExpExec: call a looked-up `exec` that is not the +/// builtin, and check what it returns. `Ok(None)` means the builtin runs. +fn execute_override( + scope: &RuntimeHandleScope, + method: &RuntimeHandle<'_>, + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, +) -> Result>, EngineError> { let callable = crate::proxy::proxy_wraps_callable(method.get_nanbox_f64()); let builtin = crate::object::regex_proto_thunks::is_builtin_regexp_exec(method.get_nanbox_f64()); @@ -119,29 +163,18 @@ pub(crate) fn execute( let argument = scope.root_nanbox_f64( input.with_const_ptr::(|input| js_nanbox_string(input as i64)), ); - let value = call_one(&method, receiver, &argument)?; + let value = call_one(method, receiver, &argument)?; if value.to_bits() == TAG_NULL { - return Ok(None); + return Ok(Some(None)); } if !crate::proxy::reflect_value_is_object(value) { return Err(EngineError::Type( "RegExp exec method must return an object or null", )); } - return Ok(Some(ExecResult::Override(value))); - } - let re = crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader; - if !super::is_valid_regex_ptr(re) { - return Err(EngineError::Type( - "RegExp builtin exec requires a RegExp receiver", - )); + return Ok(Some(Some(ExecResult::Override(value)))); } - // `execute_with_resources` roots both before it allocates. - input - .with_const_ptr::(|input| { - api::execute_with_resources(re, input, materialize, budget, memory, poll, reuse) - }) - .map(|result| result.map(ExecResult::Builtin)) + Ok(None) } pub(crate) fn to_string(value: &RuntimeHandle<'_>) -> Result<*mut StringHeader, EngineError> { diff --git a/crates/perry-runtime/src/regex/perex_memory.rs b/crates/perry-runtime/src/regex/perex_memory.rs index 2280608bd5..5a72bbedcc 100644 --- a/crates/perry-runtime/src/regex/perex_memory.rs +++ b/crates/perry-runtime/src/regex/perex_memory.rs @@ -47,6 +47,28 @@ impl MemoryBudget { } } +/// Bytes charged to an operation's limit for storage it holds without +/// allocating, such as match slots kept inline. The limit and peak see them as +/// they would a buffer's; the collector is not told, since nothing is on its +/// heap or the native heap. +pub(crate) struct Charge<'a> { + budget: &'a MemoryBudget, + bytes: usize, +} +impl<'a> Charge<'a> { + pub(crate) fn new(budget: &'a MemoryBudget, bytes: usize) -> Result { + let live = budget.check(bytes)?; + budget.live.set(live); + budget.peak.set(budget.peak.get().max(live)); + Ok(Self { budget, bytes }) + } +} +impl Drop for Charge<'_> { + fn drop(&mut self) { + self.budget.live.set(self.budget.live.get() - self.bytes); + } +} + /// Account a stable native allocation whose GC-bearing slots are separately /// registered with the host's mutable root scanner before this can collect. pub(super) struct Reservation<'a> { diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index 802b2c98b8..1f97ce41ff 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -2,7 +2,7 @@ //! is a UTF-16 span. Collection and cancellation occur outside resource views. use super::flags::CanonicalFlags; -use super::perex_memory::{Buffer, MemoryBudget, StorageError}; +use super::perex_memory::{Buffer, Charge, MemoryBudget, StorageError}; use super::perex_owner::{BuildError, GcProgram, OwnerError}; use crate::gc::RuntimeHandleScope; use perex::binding::{ @@ -106,18 +106,79 @@ pub(crate) fn compile<'scope, S: ImmutableSubject>( } } +/// Match slots a search per call needs, held inline when they fit: such a call +/// allocates nothing and notes no external bytes. A heap buffer was about a +/// tenth of every short `test` (#10166). Inline slots are still charged to the +/// operation's limit, exactly as a buffer of the same count is. Past `N` +/// slots, and for any growth, they are a heap buffer as before. +pub(crate) enum Slots<'a, T: Copy + Default, const N: usize> { + /// The slots, how many are in use, and their charge to the limit, which + /// is released when they are dropped. + Inline { + slots: [T; N], + count: usize, + _charge: Charge<'a>, + }, + Heap(Buffer<'a, T>), +} + +impl<'a, T: Copy + Default, const N: usize> Slots<'a, T, N> { + fn new(memory: &'a MemoryBudget, count: usize) -> Result { + if count <= N { + let bytes = count + .checked_mul(std::mem::size_of::()) + .ok_or(StorageError::Limit)?; + let charge = Charge::new(memory, bytes)?; + Ok(Self::Inline { + slots: [T::default(); N], + count, + _charge: charge, + }) + } else { + Buffer::new(memory, count).map(Self::Heap) + } + } +} + +impl std::ops::Deref for Slots<'_, T, N> { + type Target = [T]; + fn deref(&self) -> &[T] { + match self { + Self::Inline { slots, count, .. } => &slots[..*count], + Self::Heap(buffer) => buffer, + } + } +} + +impl std::ops::DerefMut for Slots<'_, T, N> { + fn deref_mut(&mut self) -> &mut [T] { + match self { + Self::Inline { slots, count, .. } => &mut slots[..*count], + Self::Heap(buffer) => buffer, + } + } +} + +/// Registers a program can have and still search without allocating. Frames +/// and undo entries start empty and only grow through `rebuffer`, so they are +/// never inline. +const INLINE_REGISTERS: usize = 32; +/// Capture spans an `exec` result can have and still be read without +/// allocating. +const INLINE_CAPTURES: usize = 16; + struct MatchBuffers<'a> { - registers: Buffer<'a, usize>, - frames: Buffer<'a, Frame>, - undo: Buffer<'a, Undo>, + registers: Slots<'a, usize, INLINE_REGISTERS>, + frames: Slots<'a, Frame, 0>, + undo: Slots<'a, Undo, 0>, } impl<'a> MatchBuffers<'a> { fn new(memory: &'a MemoryBudget, size: ScratchRequirements) -> Result { Ok(Self { - registers: Buffer::new(memory, size.registers)?, - frames: Buffer::new(memory, size.frames)?, - undo: Buffer::new(memory, size.undo)?, + registers: Slots::new(memory, size.registers)?, + frames: Slots::new(memory, size.frames)?, + undo: Slots::new(memory, size.undo)?, }) } } @@ -142,7 +203,7 @@ pub(crate) enum CaptureMode { pub(crate) struct Match<'a> { pub(crate) full: Span, /// None under Full. All retains unset groups and includes group zero. - pub(crate) captures: Option>>, + pub(crate) captures: Option, INLINE_CAPTURES>>, } fn search_error(error: SearchError>) -> EngineError { @@ -225,7 +286,7 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( CaptureMode::Full => None, CaptureMode::All => { poll()?; - let mut output = Buffer::new(memory, search.capture_count())?; + let mut output = Slots::new(memory, search.capture_count())?; search .copy_captures(&mut output) .map_err(EngineError::Execution)?; diff --git a/crates/perry-runtime/src/regex/perex_strings.rs b/crates/perry-runtime/src/regex/perex_strings.rs index bdc0c0c70e..110be27f42 100644 --- a/crates/perry-runtime/src/regex/perex_strings.rs +++ b/crates/perry-runtime/src/regex/perex_strings.rs @@ -151,6 +151,9 @@ pub(crate) fn copy_span_near( } .map_err(|e| read_error(e, |never| match never {})) }; + if let Some(output) = copy_ascii_span(subject, span, budget, max_output_bytes, poll)? { + return Ok(output); + } let mut readers = [reader()?, reader()?]; copy_units( Some(span.len()), @@ -166,6 +169,76 @@ pub(crate) fn copy_span_near( ) } +/// The span as one byte copy, when the subject is ASCII: its UTF-16 offsets +/// are then byte offsets, and its bytes are already the output's encoding. +/// Decoding and re-encoding it unit by unit, twice, was most of materializing +/// an `exec` result's captures (#10166). `None` for any other subject. +fn copy_ascii_span( + subject: &BoundSubject>, + span: Span, + budget: &mut Budget, + max_output_bytes: usize, + poll: &mut impl FnMut() -> Result<(), EngineError>, +) -> Result, EngineError> { + let Some(length) = subject + .with_view(|input| input.ascii_bytes().map(|bytes| bytes.len())) + .map_err(EngineError::Subject)? + else { + return Ok(None); + }; + if span.end() > length { + return Err(EngineError::InvalidSpan); + } + let units = span.len(); + let limit = max_output_bytes.min( + u32::MAX as usize - crate::gc::GC_HEADER_SIZE - std::mem::size_of::() - 7, + ); + if units > limit || units > crate::string::MAX_STRING_LENGTH { + return Err(StorageError::Limit.into()); + } + // The same charge a unit-by-unit read of the span makes. + super::perex_runtime::charge(budget, units)?; + poll()?; + let scope = RuntimeHandleScope::new(); + let capacity = units as u32; + let (output, _) = crate::string::string_storage_alloc(capacity); + // The header publishes an empty prefix until the copy below completes. No + // GC occurs before the root. + unsafe { + crate::string::init_string_header(output, 0, 0, capacity, 0, 0); + } + let output = scope.root_string_ptr(output); + output.with_mut_ptr::(|header| { + // Reacquire both bases inside one scope that neither allocates nor + // collects. + subject + .with_view(|input| { + let bytes = input.ascii_bytes().ok_or(EngineError::InvalidSpan)?; + let source = bytes + .get(span.start()..span.end()) + .ok_or(EngineError::InvalidSpan)?; + let data = unsafe { + std::slice::from_raw_parts_mut( + crate::string::string_data(header) as *mut MaybeUninit, + capacity as usize, + ) + }; + for (slot, &byte) in data.iter_mut().zip(source) { + // GC_STORE_AUDIT(POINTER_FREE): ASCII payload bytes of a string under construction. + slot.write(byte); + } + unsafe { + crate::string::init_string_header(header, capacity, capacity, capacity, 0, 0); + } + Ok::<(), EngineError>(()) + }) + .map_err(EngineError::Subject)? + })?; + Ok(Some( + output.with_mut_ptr::(|output| output), + )) +} + /// Two reusable original-string cursors for a sequence of final substrings. /// Each pass retains its own position, so adjacent split pieces do not seek /// repeatedly from the beginning of a non-ASCII input. Only offsets survive GC. diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index c3ebab9f30..1d4eba15bf 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -823,6 +823,12 @@ "scanner": "regex::regex_header_moved_for_gc (called from gc/types.rs on relocation), regex::regex_header_finalize_for_gc (gc/types.rs per-object finalize), regex::finalize_dead_copied_minor_from_space_regexps (gc/copying_phase.rs) and regex::collect_dead_registered_regexps_post_trace / finalize_collected_dead_regexp (gc/oldgen.rs)", "why": "Address-KEYED owner set, not a root, and the successor of REGEX_POINTERS under the single engine: its map is `usize` header address -> `RegexMetadata { registered_owner: bool }`, so the VALUE holds no heap address at all (source and flags live only in the header's traced string edges, and the compiled program is a traced GC child of the header). The key is rekeyed by `regex_header_moved_for_gc` when a RegExpHeader moves and removed on death by the finalize hook, the copying-minor from-space walk and the full-cycle post-trace walk; it never keeps a header alive. Reached from those GC hooks rather than a registered scanner, so the walk misses it." }, + { + "file": "crates/perry-runtime/src/regex/perex_dispatch.rs", + "name": "EXEC_LOOKUPS", + "verdict": "test_only", + "why": "#[cfg(test)] Cell counting Get(R, \"exec\") lookups taken by `execute`, so a test can assert the lookup is skipped only when unobservable (#10166). It stores a count, never an address, and is absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/regex/perex_position_hint.rs", "name": "HINT_USES",