From 5fa21d44d53369bf94f93d0cd662ce464f2fa7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 12:36:13 +0200 Subject: [PATCH 1/2] size: compile the URL-shape probes only under url-engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six sites on generic value paths carry a runtime "is this object URL-shaped?" probe: value/to_string.rs ToString(obj) value/dynamic_arith.rs obj + x coercion json/replacer.rs (x2) JSON.stringify compact + pretty walks object/object_ops/from_entries.rs (x2) Object.fromEntries object/field_set_by_name.rs obj. = v (8 js_url_set_* entry points) The probes are guarded at RUNTIME, which does nothing for binary size: the static references keep the URL class and parser alive in every program. `-why_live` on hello world showed the parser reached through the plainest possible chain — `js_object_set_field_by_name` -> `js_url_set_href` -> `create_url_object` — so any program that assigns an object field pinned it. Compile the probes under `url-engine`. `ctx.uses_url` is what enables that feature and is zero-false-negative by construction (it matches the lowered `UrlNew`/`UrlParse`/`UrlGet`/`UrlSet`/`UrlInstance`/`UrlSearchParams`/ `UrlPattern` nodes and `module: "url"`), so a program that cannot name the URL API cannot own a URL or URLSearchParams for these probes to find. hello world: 4,708,736 -> 4,675,672 bytes (-33,064). Note this is narrower than `crate::url` as a whole: `crate::url::abort` (AbortController/AbortSignal, used by dgram / fs watch / child_process / node_stream) and the URL constructor thunks in `object/global_this/install_static.rs` are untouched. Behavior change, deliberate and stated: in a binary built WITHOUT `url-engine`, a plain object that coincidentally matches the URL shape (class_id 0, >= URL_FIELD_COUNT fields, and a field that parses as an absolute URL) no longer gets URL treatment on these paths. It cannot be a real URL there, so this only affects a pathological look-alike, where falling through to ordinary object semantics is the more correct answer. Verified: - `cargo check -p perry-runtime` on both cfg paths; `cargo fmt` clean. - New gap test exercises ALL six sites (String(u), "" + u, three setters, JSON.stringify({u}), Object.fromEntries(sp)) and matches `node --experimental-strip-types` byte for byte. - hello world still runs. --- changelog.d/7435-gate-url-shape-probes.md | 1 + crates/perry-runtime/src/json/replacer.rs | 2 ++ .../src/object/field_set_by_name/tail.rs | 5 ++++ .../src/object/object_ops/from_entries.rs | 2 ++ .../perry-runtime/src/value/dynamic_arith.rs | 23 +++++++++------- crates/perry-runtime/src/value/to_string.rs | 27 +++++++++++++------ test-files/test_gap_url_shape_probe_gate.ts | 19 +++++++++++++ 7 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 changelog.d/7435-gate-url-shape-probes.md create mode 100644 test-files/test_gap_url_shape_probe_gate.ts diff --git a/changelog.d/7435-gate-url-shape-probes.md b/changelog.d/7435-gate-url-shape-probes.md new file mode 100644 index 0000000000..e852327fdb --- /dev/null +++ b/changelog.d/7435-gate-url-shape-probes.md @@ -0,0 +1 @@ +size: the URL-shape probes on generic value paths (`ToString`, `+` coercion, `JSON.stringify`, `Object.fromEntries`, by-name field set) are now compiled only under `url-engine` — they were pinning the URL parser into every binary. Hello world drops 33,064 bytes (4,708,736 → 4,675,672). diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index 0d2c319fc4..1dfd9bbcd6 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -274,6 +274,7 @@ unsafe fn dispatch_pointer_with_replacer( // the URL, so the generic object walk below would trip the circular- // structure detector. The href is a plain string, so the emit is identical // for compact and pretty walks. See `write_url_href_json`. + #[cfg(feature = "url-engine")] if crate::url::is_url_object_shape(ptr as *mut crate::ObjectHeader) { super::stringify::write_url_href_json(ptr as *mut crate::ObjectHeader, buf); return; @@ -823,6 +824,7 @@ pub(crate) unsafe fn stringify_value_pretty( // be walked as a plain object (its `searchParams` back-reference trips // the circular-structure detector). Mirrors the compact-path branch in // `stringify_object_inner`; see `write_url_href_json`. + #[cfg(feature = "url-engine")] if crate::url::is_url_object_shape(ptr as *mut crate::ObjectHeader) { super::stringify::write_url_href_json(ptr as *mut crate::ObjectHeader, buf); return; diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index 03260f8bd7..e028699e7a 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -314,6 +314,11 @@ pub(super) fn set_field_by_name_object_tail( } } + // Binary size: this whole arm statically references eight + // `js_url_set_*` entry points, which is what kept the URL parser in + // every binary. Gated with the other URL shape probes; see + // `value/to_string.rs`. + #[cfg(feature = "url-engine")] if !plan_fast && !key.is_null() && (key as usize) > 0x10000 diff --git a/crates/perry-runtime/src/object/object_ops/from_entries.rs b/crates/perry-runtime/src/object/object_ops/from_entries.rs index 50863a0e1c..434a86829b 100644 --- a/crates/perry-runtime/src/object/object_ops/from_entries.rs +++ b/crates/perry-runtime/src/object/object_ops/from_entries.rs @@ -59,6 +59,7 @@ unsafe fn object_from_entries_has_iterator(value: f64, raw: i64, gc_type: Option | Some(crate::gc::GC_TYPE_SET) => return true, Some(crate::gc::GC_TYPE_OBJECT) => { let obj = raw as *mut ObjectHeader; + #[cfg(feature = "url-engine")] if crate::url::try_read_as_search_params(obj).is_some() { return true; } @@ -109,6 +110,7 @@ unsafe fn object_from_entries_materialize_entries(entries_value: f64) -> *mut Ar if gc_type == Some(crate::gc::GC_TYPE_OBJECT) { let obj = raw as *mut ObjectHeader; + #[cfg(feature = "url-engine")] if crate::url::try_read_as_search_params(obj).is_some() { let boxed = crate::url::js_url_search_params_entries_arr(obj); return object_from_entries_array_ptr(boxed); diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index 061d39f8d2..00d5c26754 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -278,15 +278,20 @@ unsafe fn to_primitive_default_for_add(value: f64) -> f64 { if !crate::value::addr_class::is_handle_band(ptr) { let boxed = f64::from_bits(crate::value::POINTER_TAG | ((ptr as u64) & crate::value::POINTER_MASK)); - let href = crate::url::url_class::js_url_href_if_url(boxed); - if href.to_bits() != crate::value::TAG_UNDEFINED { - let s = js_jsvalue_to_string(href); - return crate::value::js_nanbox_string(s as i64); - } - let obj = ptr as *mut crate::object::ObjectHeader; - if crate::url::try_read_as_search_params(obj).is_some() { - let s = crate::url::search_params::js_url_search_params_to_string(obj); - return crate::value::js_nanbox_string(s as i64); + // See the matching note in `value/to_string.rs`: shape probes on a + // generic path, kept out of non-URL binaries so the parser can strip. + #[cfg(feature = "url-engine")] + { + let href = crate::url::url_class::js_url_href_if_url(boxed); + if href.to_bits() != crate::value::TAG_UNDEFINED { + let s = js_jsvalue_to_string(href); + return crate::value::js_nanbox_string(s as i64); + } + let obj = ptr as *mut crate::object::ObjectHeader; + if crate::url::try_read_as_search_params(obj).is_some() { + let s = crate::url::search_params::js_url_search_params_to_string(obj); + return crate::value::js_nanbox_string(s as i64); + } } } diff --git a/crates/perry-runtime/src/value/to_string.rs b/crates/perry-runtime/src/value/to_string.rs index 0bfc25b712..00f85d491c 100644 --- a/crates/perry-runtime/src/value/to_string.rs +++ b/crates/perry-runtime/src/value/to_string.rs @@ -1164,16 +1164,27 @@ pub extern "C" fn js_jsvalue_to_string(value: f64) -> *mut crate::string::String // unmapped memory. if !crate::value::addr_class::is_handle_band(ptr as usize) { let boxed = f64::from_bits(POINTER_TAG | ((ptr as u64) & POINTER_MASK)); - let url_href = crate::url::url_class::js_url_href_if_url(boxed); - if url_href.to_bits() != crate::value::TAG_UNDEFINED { - return js_jsvalue_to_string(url_href); - } - if crate::url::try_read_as_search_params(ptr as *mut crate::object::ObjectHeader) - .is_some() + // Binary size: these are SHAPE probes on a generic path, so the + // static reference keeps the whole URL class + parser alive in + // every binary even though the runtime check can never pass + // without `url-engine`. `uses_url` (zero-false-negative by + // construction) is what turns that feature on, so a program + // with no URL API cannot own a URL or URLSearchParams here. + #[cfg(feature = "url-engine")] { - return crate::url::search_params::js_url_search_params_to_string( + let url_href = crate::url::url_class::js_url_href_if_url(boxed); + if url_href.to_bits() != crate::value::TAG_UNDEFINED { + return js_jsvalue_to_string(url_href); + } + if crate::url::try_read_as_search_params( ptr as *mut crate::object::ObjectHeader, - ); + ) + .is_some() + { + return crate::url::search_params::js_url_search_params_to_string( + ptr as *mut crate::object::ObjectHeader, + ); + } } } // OrdinaryToPrimitive(obj, "string"): the object has no diff --git a/test-files/test_gap_url_shape_probe_gate.ts b/test-files/test_gap_url_shape_probe_gate.ts new file mode 100644 index 0000000000..178455773c --- /dev/null +++ b/test-files/test_gap_url_shape_probe_gate.ts @@ -0,0 +1,19 @@ +// The generic value paths (`ToString`, `+` coercion, `JSON.stringify`, +// `Object.fromEntries`, and the by-name field setter) each carry a runtime +// "is this object URL-shaped?" probe. Those probes are compiled only under +// `url-engine`, because their static references otherwise pin the whole URL +// parser into every binary. This file uses the URL API, so `uses_url` turns +// the feature on and every probe below must behave exactly as before. + +const u = new URL("https://user:pw@example.com:8443/a/b?x=1&y=2#frag"); +console.log(String(u)); +console.log("" + u); +console.log(u.hostname, u.port, u.pathname, u.search, u.hash); +u.pathname = "/changed"; +u.search = "?z=9"; +u.href = "https://other.example/zzz?q=1"; +console.log(u.href, u.hostname); +const sp = new URLSearchParams("a=1&b=2"); +console.log(String(sp), "" + sp); +console.log(JSON.stringify({ u, sp: String(sp) })); +console.log(JSON.stringify(Object.fromEntries(sp))); From 960cae291a86034994e260645ce712cedd8797b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 14:15:50 +0200 Subject: [PATCH 2/2] gc: bind url probe locals inside their feature gate The gates left `boxed`/`obj` bound outside the `#[cfg]` block that was their only consumer, so a url-engine-off build gained three `unused variable` warnings. Moving each binding inside its gate restores the off-arm warning set to byte-identical with main. --- crates/perry-runtime/src/object/object_ops/from_entries.rs | 4 +++- crates/perry-runtime/src/value/dynamic_arith.rs | 6 ++++-- crates/perry-runtime/src/value/to_string.rs | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/object/object_ops/from_entries.rs b/crates/perry-runtime/src/object/object_ops/from_entries.rs index 434a86829b..4e242c0deb 100644 --- a/crates/perry-runtime/src/object/object_ops/from_entries.rs +++ b/crates/perry-runtime/src/object/object_ops/from_entries.rs @@ -108,9 +108,11 @@ unsafe fn object_from_entries_materialize_entries(entries_value: f64) -> *mut Ar return crate::map::js_map_entries(raw as *const crate::map::MapHeader); } + // The whole arm exists to serve `URLSearchParams`, so it goes with the + // feature rather than leaving `obj` bound for a body that isn't compiled. + #[cfg(feature = "url-engine")] if gc_type == Some(crate::gc::GC_TYPE_OBJECT) { let obj = raw as *mut ObjectHeader; - #[cfg(feature = "url-engine")] if crate::url::try_read_as_search_params(obj).is_some() { let boxed = crate::url::js_url_search_params_entries_arr(obj); return object_from_entries_array_ptr(boxed); diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index 00d5c26754..f023a465da 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -276,12 +276,14 @@ unsafe fn to_primitive_default_for_add(value: f64) -> f64 { // registry ids, not heap `ObjectHeader`s, so the shape probe would // dereference unmapped memory. if !crate::value::addr_class::is_handle_band(ptr) { - let boxed = - f64::from_bits(crate::value::POINTER_TAG | ((ptr as u64) & crate::value::POINTER_MASK)); // See the matching note in `value/to_string.rs`: shape probes on a // generic path, kept out of non-URL binaries so the parser can strip. + // `boxed` is bound inside the gate: it feeds only these probes. #[cfg(feature = "url-engine")] { + let boxed = f64::from_bits( + crate::value::POINTER_TAG | ((ptr as u64) & crate::value::POINTER_MASK), + ); let href = crate::url::url_class::js_url_href_if_url(boxed); if href.to_bits() != crate::value::TAG_UNDEFINED { let s = js_jsvalue_to_string(href); diff --git a/crates/perry-runtime/src/value/to_string.rs b/crates/perry-runtime/src/value/to_string.rs index 00f85d491c..85f6f2fe4d 100644 --- a/crates/perry-runtime/src/value/to_string.rs +++ b/crates/perry-runtime/src/value/to_string.rs @@ -1163,15 +1163,16 @@ pub extern "C" fn js_jsvalue_to_string(value: f64) -> *mut crate::string::String // heap `ObjectHeader`s, so the shape check would dereference // unmapped memory. if !crate::value::addr_class::is_handle_band(ptr as usize) { - let boxed = f64::from_bits(POINTER_TAG | ((ptr as u64) & POINTER_MASK)); // Binary size: these are SHAPE probes on a generic path, so the // static reference keeps the whole URL class + parser alive in // every binary even though the runtime check can never pass // without `url-engine`. `uses_url` (zero-false-negative by // construction) is what turns that feature on, so a program // with no URL API cannot own a URL or URLSearchParams here. + // `boxed` is bound inside the gate: it feeds only these probes. #[cfg(feature = "url-engine")] { + let boxed = f64::from_bits(POINTER_TAG | ((ptr as u64) & POINTER_MASK)); let url_href = crate::url::url_class::js_url_href_if_url(boxed); if url_href.to_bits() != crate::value::TAG_UNDEFINED { return js_jsvalue_to_string(url_href);