From a99907e9606e8eb4dbf48f2e25f75e929bb9ac0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 23:14:02 +0200 Subject: [PATCH 1/3] perf(runtime): hoist the private-member guard to its call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8970 made the private-member name test cheap but left the CALL: a pure property-read loop still spent 16.8% in private_member_get_by_name and private_member_storage_name, essentially all of it call overhead for keys that are rejected on their length. Export the guard and invoke it at the three call sites — the read entry, the class-field read miss, and the generic write — so an ordinary property operation makes no call into the private-member path at all. Keys that pass the guard still take exactly the original path. Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP --- changelog.d/8978-private-guard-call-site.md | 22 +++++++++++++++++++ .../perry-runtime/src/object/field_get_set.rs | 14 ++++++------ .../object/field_get_set/get_field_by_name.rs | 9 ++++++-- .../src/object/field_get_set/ic_miss.rs | 6 +++-- .../ic_miss/private_member_access.rs | 9 +++++++- .../src/object/field_set_by_name.rs | 6 ++++- 6 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 changelog.d/8978-private-guard-call-site.md diff --git a/changelog.d/8978-private-guard-call-site.md b/changelog.d/8978-private-guard-call-site.md new file mode 100644 index 0000000000..5791531d9e --- /dev/null +++ b/changelog.d/8978-private-guard-call-site.md @@ -0,0 +1,22 @@ +The private-member guard moved to its call sites, taking a call off every +ordinary property read and write. + +#8970 made the private-member name test cheap but left the CALL. In a pure +property-read loop (`o[k]` with pre-built keys, no concat) that showed up as +`private_member_get_by_name` 11.4% plus `private_member_storage_name` 5.4% — +**16.8% of the loop, the largest single item** — essentially all of it call +overhead for keys that are rejected on their length before doing anything. + +The guard is now invoked at the three call sites (the generic read entry, the +class-field read miss, and the generic write), so an ordinary property +operation makes no call into the private-member path at all. Keys that pass +the guard take exactly the original path. + +Interleaved A/B, min-of-21 (under heavy co-tenant load, so read the ratios +rather than the absolutes): pure property read 26 → 22 ms (−15%), computed-key +read 51 → 45 ms (−12%), combined overwrite 50 → 46 ms (−8%), write unchanged. + +Output on a private-member exercise — instance fields, `static #instances`, +private methods, private getters, `#x in obj`, subclassing, and an ordinary key +literally named `#` — is byte-identical to before the +change. Computed-key differential vs node is byte-identical. Suite 2779 passed. diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index ebe7768f7e..695c2eef4d 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -284,13 +284,13 @@ pub use has_property::{js_in_operator, js_object_has_property}; #[cfg(test)] pub(crate) use ic_miss::primitive_proto_method_name_static; pub(crate) use ic_miss::{ - bind_primitive_proto_method_static, is_array_method_value_name, private_evaluation_brand_value, - private_lexical_brand_pop, private_lexical_brand_push, private_lexical_brand_stack_restore, - private_lexical_brand_stack_savepoint, private_member_access_hints_restore, - private_member_access_hints_savepoint, private_member_call_by_name, private_member_get_by_name, - private_member_set_by_name, scan_private_lexical_brand_roots_mut, set_method_value_name, - stamp_private_evaluation_brand, take_private_method_call_hint, take_private_method_owner_hint, - timer_handle_method_name_static, + bind_primitive_proto_method_static, cannot_be_private_member_name, is_array_method_value_name, + private_evaluation_brand_value, private_lexical_brand_pop, private_lexical_brand_push, + private_lexical_brand_stack_restore, private_lexical_brand_stack_savepoint, + private_member_access_hints_restore, private_member_access_hints_savepoint, + private_member_call_by_name, private_member_get_by_name, private_member_set_by_name, + scan_private_lexical_brand_roots_mut, set_method_value_name, stamp_private_evaluation_brand, + take_private_method_call_hint, take_private_method_owner_hint, timer_handle_method_name_static, }; pub use ic_miss::{ js_class_field_add, js_object_get_field_by_name_f64, js_object_get_field_by_property_id_f64, diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index d9fdf0e043..2e77c64d7d 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -27,8 +27,13 @@ pub extern "C" fn js_object_get_field_by_name( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> JSValue { - if let Some(value) = super::private_member_get_by_name(obj, key) { - return JSValue::from_bits(value.to_bits()); + // Guard hoisted to the call site: an ordinary key is rejected on a length + // compare and one byte here, so the overwhelmingly common property read + // makes no call into the private-member path at all. + if !super::cannot_be_private_member_name(key) { + if let Some(value) = super::private_member_get_by_name(obj, key) { + return JSValue::from_bits(value.to_bits()); + } } // An elements-backed Array-subclass instance answers its indices and // `length` from its store; an absent index falls through to the ordinary diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index dd0ead0dbd..c6210d12cd 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -10,8 +10,10 @@ pub extern "C" fn js_object_get_field_by_name_f64( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> f64 { - if let Some(value) = private_member_get_by_name(obj, key) { - return value; + if !cannot_be_private_member_name(key) { + if let Some(value) = private_member_get_by_name(obj, key) { + return value; + } } if (obj as usize) > 0 && (obj as usize) < 0x10000 && !key.is_null() { if let Some(name) = unsafe { super::super::has_own_helpers::str_from_string_header(key) } { diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs index 5569f67205..44849f6265 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs @@ -46,6 +46,13 @@ const PRIVATE_MEMBER_PREFIX: &str = "# bool { +pub(crate) fn cannot_be_private_member_name(key: *const crate::StringHeader) -> bool { if key.is_null() { return true; } diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 4447c63345..fa6dcaec64 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -44,7 +44,11 @@ pub extern "C" fn js_object_set_field_by_name( key: *const crate::StringHeader, value: f64, ) { - if super::private_member_set_by_name(obj, key, value) { + // Guard hoisted to the call site (see `cannot_be_private_member_name`): + // an ordinary key never calls into the private-member path. + if !super::cannot_be_private_member_name(key) + && super::private_member_set_by_name(obj, key, value) + { return; } // A heap class value is an exotic constructor object. Its own From 378d95527056d9fcedf8b3e2f260bb1550aa1bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 23:24:46 +0200 Subject: [PATCH 2/3] fix(run): resolve directory inputs to project entry (#8979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(run): resolve directory inputs to project entry * chore(changelog): add the missing fragment for the run-directory fix The changeset gate requires changelog.d/-.md for any crates/ change unless the PR carries skip-changelog; this PR had neither. --------- Co-authored-by: Ralph Küpper --- changelog.d/8979-run-directory-entry.md | 5 +++ crates/perry/src/commands/run/entry.rs | 49 +++++++++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 changelog.d/8979-run-directory-entry.md diff --git a/changelog.d/8979-run-directory-entry.md b/changelog.d/8979-run-directory-entry.md new file mode 100644 index 0000000000..bf426955b6 --- /dev/null +++ b/changelog.d/8979-run-directory-entry.md @@ -0,0 +1,5 @@ +`perry run ` now resolves the directory to its project entry instead of passing the directory itself into module collection. + +An explicit directory input is treated as a project root: its `perry.toml` entry is read relative to that directory, falling back to `/src/main.ts` then `/main.ts`. Previously `perry run .` read `perry.toml` from the current working directory and handed the directory straight to module collection, which fails on Windows. + +Fixes #8908. diff --git a/crates/perry/src/commands/run/entry.rs b/crates/perry/src/commands/run/entry.rs index e6daef034f..406422a319 100644 --- a/crates/perry/src/commands/run/entry.rs +++ b/crates/perry/src/commands/run/entry.rs @@ -41,15 +41,17 @@ pub fn rust_target_triple(target: Option<&str>) -> Option<&'static str> { /// Resolve the entry TypeScript file pub fn resolve_entry_file(input: Option<&Path>) -> Result { - if let Some(path) = input { - if path.exists() { - return Ok(path.to_path_buf()); + let project_dir = match input { + Some(path) if !path.exists() => { + return Err(anyhow!("File not found: {}", path.display())); } - return Err(anyhow!("File not found: {}", path.display())); - } + Some(path) if !path.is_dir() => return Ok(path.to_path_buf()), + Some(path) => path, + None => Path::new("."), + }; // Try perry.toml - if let Some(entry) = read_perry_toml_entry() { + if let Some(entry) = read_perry_toml_entry(project_dir) { if entry.exists() { return Ok(entry); } @@ -57,7 +59,7 @@ pub fn resolve_entry_file(input: Option<&Path>) -> Result { // Fallback: src/main.ts, then main.ts for candidate in &["src/main.ts", "main.ts"] { - let path = PathBuf::from(candidate); + let path = project_dir.join(candidate); if path.exists() { return Ok(path); } @@ -71,14 +73,14 @@ pub fn resolve_entry_file(input: Option<&Path>) -> Result { } /// Read entry point from perry.toml if present -pub fn read_perry_toml_entry() -> Option { - let toml_str = std::fs::read_to_string("perry.toml").ok()?; +fn read_perry_toml_entry(project_dir: &Path) -> Option { + let toml_str = std::fs::read_to_string(project_dir.join("perry.toml")).ok()?; for line in toml_str.lines() { let trimmed = line.trim(); if trimmed.starts_with("entry") { if let Some(eq_pos) = trimmed.find('=') { let value = trimmed[eq_pos + 1..].trim().trim_matches('"'); - return Some(PathBuf::from(value)); + return Some(project_dir.join(value)); } } } @@ -272,7 +274,32 @@ pub fn resolve_target( #[cfg(test)] mod tests { - use super::rust_target_triple; + use super::{resolve_entry_file, rust_target_triple}; + + #[test] + fn directory_input_resolves_default_entry() { + let project = tempfile::tempdir().unwrap(); + let entry = project.path().join("src/main.ts"); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + std::fs::write(&entry, "console.log('hello');").unwrap(); + + assert_eq!(resolve_entry_file(Some(project.path())).unwrap(), entry); + } + + #[test] + fn directory_input_resolves_perry_toml_entry() { + let project = tempfile::tempdir().unwrap(); + let entry = project.path().join("app/index.ts"); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + std::fs::write(&entry, "console.log('hello');").unwrap(); + std::fs::write( + project.path().join("perry.toml"), + "entry = \"app/index.ts\"\n", + ) + .unwrap(); + + assert_eq!(resolve_entry_file(Some(project.path())).unwrap(), entry); + } #[test] fn android_x86_64_uses_its_cross_runtime() { From 2e7f3a6c60583cfa628de64b3942bf1d5a156154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 23:29:52 +0200 Subject: [PATCH 3/3] chore(changelog): name the fragment for its own PR (8980, not 8978) --- ...private-guard-call-site.md => 8980-private-guard-call-site.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{8978-private-guard-call-site.md => 8980-private-guard-call-site.md} (100%) diff --git a/changelog.d/8978-private-guard-call-site.md b/changelog.d/8980-private-guard-call-site.md similarity index 100% rename from changelog.d/8978-private-guard-call-site.md rename to changelog.d/8980-private-guard-call-site.md