From 3d7c65fd4cf632cbf33b000a47f5b4579b48d216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 14:39:48 +0000 Subject: [PATCH 1/2] fix(runtime): add Symbol.toStringTag to Web/runtime built-ins Object.prototype.toString.call(x) fell through to the generic [object Object] for URL, URLSearchParams, Headers, Request, Response, FormData, Blob, File, AbortController, AbortSignal, TextEncoder, TextDecoder, EventTarget, Event and CustomEvent, and x[Symbol.toStringTag] read back undefined -- breaking the standard cross-realm type check utility/HTTP libraries use (axios decides body serialization this way). Two representations, two gaps: the Web Fetch family and TextEncoder/ TextDecoder are small-integer registry handles with no brand/property case; URL/URLSearchParams and AbortController/AbortSignal/EventTarget/ Event/CustomEvent are real objects whose instances are never linked to their .prototype via object_static_prototype, so a property installed only there would never be reached from an instance. A new web_builtin_to_string_tag answers both Object.prototype.toString and x[Symbol.toStringTag] from one place, and a real, correctly-shaped descriptor is also installed on each constructor's own .prototype for reflection. Fixes #10555 --- .../src/object/global_this/proto_methods.rs | 61 ++++++++++++++ crates/perry-runtime/src/object/mod.rs | 1 + crates/perry-runtime/src/object/tests.rs | 29 +++++-- .../perry-runtime/src/object/to_string_tag.rs | 82 +++++++++++++++++++ crates/perry-runtime/src/symbol/get.rs | 31 +++++++ .../perry-stdlib/src/fetch/body_metadata.rs | 9 ++ crates/perry-stdlib/src/fetch/dispatch.rs | 15 +++- ...p_10555_symbol_tostringtag_web_builtins.ts | 60 ++++++++++++++ 8 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index 29f60e3d82..fa3237e267 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -4,6 +4,45 @@ use super::*; // `array_proto_*_thunk` without routing through the trunk re-exports. use super::array_error::*; +/// Install a FIXED-string `Symbol.toStringTag` data property (`{ value: tag, +/// writable: false, enumerable: false, configurable: true }`, ES2019 +/// WebIDL/`get %TypedArray%.prototype [ @@toStringTag ]` sibling shape but a +/// plain data property rather than a getter -- these Web API interfaces +/// each own a single fixed tag, unlike the shared TypedArray prototype) on +/// `proto_obj`. #10555: `Object.prototype.toString.call(x)` and +/// `x[Symbol.toStringTag]` for these types are ALSO answered directly by +/// `crate::object::web_builtin_to_string_tag` (`object/to_string_tag.rs`) +/// for every instance shape that reaches it -- most of these types' own +/// instances never link `[[Prototype]]` back to this very `proto_obj` (see +/// that function's doc comment), so that synthesized answer is load-bearing +/// for `x[Symbol.toStringTag]`/`toString.call(x)` on an INSTANCE. This +/// installs the matching descriptor on the constructor's `.prototype` +/// object itself so `Object.getOwnPropertyDescriptor(Ctor.prototype, +/// Symbol.toStringTag)` also reflects a real, correctly-shaped descriptor +/// (test262-style reflection, and libraries that copy descriptors off the +/// prototype rather than reading the instance). +unsafe fn install_web_builtin_to_string_tag(proto_obj: *mut ObjectHeader, tag: &str) { + if proto_obj.is_null() { + return; + } + let symbol = crate::symbol::well_known_symbol("toStringTag"); + if symbol.is_null() { + return; + } + let key = crate::string::js_string_from_bytes(tag.as_ptr(), tag.len() as u32); + let value = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); + crate::symbol::js_object_set_symbol_property( + crate::value::js_nanbox_pointer(proto_obj as i64), + crate::value::js_nanbox_pointer(symbol as i64), + value, + ); + crate::symbol::set_symbol_property_attrs( + proto_obj as usize, + symbol as usize, + crate::object::PropertyAttrs::new(false, false, true), + ); +} + /// Universal `Object.prototype` methods inherited by every receiver in /// JS. Installed on every built-in constructor's prototype since Perry's /// prototype chain on these built-ins doesn't walk back up to a shared @@ -685,11 +724,13 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: "TextEncoder" => { install_noop_proto_methods(proto_obj, &[("encode", 1), ("encodeInto", 2)]); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "TextEncoder") }; } #[cfg(feature = "global-text")] "TextDecoder" => { install_noop_proto_methods(proto_obj, &[("decode", 1)]); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "TextDecoder") }; } #[cfg(feature = "global-webfetch")] "Headers" => { @@ -709,6 +750,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ], ); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "Headers") }; } #[cfg(feature = "global-webfetch")] "Request" | "Response" => { @@ -781,6 +823,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: } } install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, builtin_name) }; } #[cfg(feature = "global-webfetch")] "Blob" | "File" => { @@ -795,6 +838,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ], ); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, builtin_name) }; } #[cfg(feature = "global-webfetch")] "FormData" => { @@ -814,6 +858,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ], ); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "FormData") }; } #[cfg(feature = "global-websocket")] "WebSocket" => { @@ -935,6 +980,22 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: // is wired alongside the `OBJ_FLAG_TYPED_ARRAY_PROTO` flag so the // generic property-get chain walk resolves the inherited methods. } + // #10555: these Web API types install NO methods here (their surface + // is either type-directed static dispatch or the small-int/handle + // dispatch tables), but each still needs its `.prototype`'s own + // `Symbol.toStringTag` descriptor for reflection -- see + // `install_web_builtin_to_string_tag`'s doc comment. + "URL" => unsafe { install_web_builtin_to_string_tag(proto_obj, "URL") }, + "URLSearchParams" => unsafe { + install_web_builtin_to_string_tag(proto_obj, "URLSearchParams") + }, + "AbortController" => unsafe { + install_web_builtin_to_string_tag(proto_obj, "AbortController") + }, + "AbortSignal" => unsafe { install_web_builtin_to_string_tag(proto_obj, "AbortSignal") }, + "EventTarget" => unsafe { install_web_builtin_to_string_tag(proto_obj, "EventTarget") }, + "Event" => unsafe { install_web_builtin_to_string_tag(proto_obj, "Event") }, + "CustomEvent" => unsafe { install_web_builtin_to_string_tag(proto_obj, "CustomEvent") }, _ => {} } } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index f5644e6c80..ff07b47b81 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -314,6 +314,7 @@ pub use this_binding::{ }; pub use to_string_tag::js_object_to_string; pub(crate) use to_string_tag::typed_array_to_string_tag_name; +pub(crate) use to_string_tag::web_builtin_to_string_tag; /// An atomic GC root whose backing slot belongs to the calling Perry agent. /// diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index ec8b14aa3d..43f8270dac 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -1309,12 +1309,11 @@ fn wide_object_own_key_present_uses_index_and_object_values_is_complete() { /// `js_object_to_string` must NOT dereference a handle-band value (a Web Fetch /// `Headers`/`Request`/`Response`/`Blob` registry id, or any other small native -/// handle) as a heap pointer. Such ids are NaN-boxed as `POINTER_TAG` values but -/// are not `GcHeader`-prefixed objects; reading the GC type byte at `id - 8` (or -/// `(*ObjectHeader).class_id` at `id`) faults on unmapped low memory. This is -/// the `claude -p` SIGSEGV (`EXC_BAD_ACCESS` at `0x3FFFB` == `0x40003 - 8`), -/// where the SDK coerced a `Headers` handle to a string while building a -/// request. The brand must fall through to the generic `[object Object]` tag. +/// handle) as a heap pointer -- `id - 8` / `id` faults on unmapped low memory. +/// This is the `claude -p` SIGSEGV (`EXC_BAD_ACCESS` at `0x3FFFB` == +/// `0x40003 - 8`). Every id here is unclaimed in a bare unit-test process +/// (not `TEXT_ENCODER_SENTINEL_ID` either -- see the sibling test below), so +/// the brand must fall through to the generic `[object Object]` tag. #[test] fn object_to_string_rejects_handle_band_ids() { use crate::value::addr_class; @@ -1322,9 +1321,14 @@ fn object_to_string_rejects_handle_band_ids() { addr_class::FETCH_HANDLE_BAND_START, // 0x40000 addr_class::FETCH_HANDLE_BAND_START + 3, // the 0x40003 from the crash addr_class::HANDLE_BAND_MAX - 1, // 0xFFFFF - 1usize, // common native handle + 3usize, // common native handle, unclaimed ] { assert!(addr_class::is_handle_band(id)); + assert_ne!( + id, + crate::text::TEXT_ENCODER_SENTINEL_ID as usize, + "must not pick an id #10555 gives real meaning to" + ); let handle = crate::value::js_nanbox_pointer(id as i64); // Must return a string brand without dereferencing the bogus pointer. let result = unsafe { js_object_to_string(handle) }; @@ -1336,6 +1340,17 @@ fn object_to_string_rejects_handle_band_ids() { } } +/// #10555: `TEXT_ENCODER_SENTINEL_ID` is the id every `TextEncoder` shares -- +/// unlike the ids above, `js_object_to_string` must brand it `TextEncoder` +/// unconditionally, matching the runtime's own treatment of that id. +#[test] +fn object_to_string_brands_the_text_encoder_sentinel() { + let handle = crate::value::js_nanbox_pointer(crate::text::TEXT_ENCODER_SENTINEL_ID); + let result = unsafe { js_object_to_string(handle) }; + let s = js_string_to_rust(JSValue::from_bits(result.to_bits())); + assert_eq!(s, "[object TextEncoder]"); +} + /// #5437 — captured-`undefined` tag-loss on Next.js dynamic/API routes. /// /// `js_class_capture_value_or` must NOT replace a snapshot whose slot is a diff --git a/crates/perry-runtime/src/object/to_string_tag.rs b/crates/perry-runtime/src/object/to_string_tag.rs index da985ed099..1b17f6b5ab 100644 --- a/crates/perry-runtime/src/object/to_string_tag.rs +++ b/crates/perry-runtime/src/object/to_string_tag.rs @@ -16,6 +16,82 @@ pub(crate) fn web_stream_to_string_tag(value: f64) -> Option<&'static str> { } } +/// `Symbol.toStringTag` for Perry's Web/runtime built-ins that carry no +/// registered class-id hook and (for the handle-backed ones) no real +/// `ObjectHeader` at all (#10555): `URL`/`URLSearchParams` (ordinary +/// class_id-0 objects, detected structurally — see `is_url_object_shape` / +/// `shape_is_url_search_params`), the Web Fetch family `Headers` / `Request` +/// / `Response` / `Blob` / `FormData` (small-int handles owned by +/// `perry-stdlib`, reached through `fetch_handle_kind_probe` — the same +/// probe `instanceof` already uses), `TextEncoder` / `TextDecoder` (small-int +/// handles owned by this crate's own `text` module), and the class-id-tagged +/// `AbortController` / `AbortSignal` / `EventTarget` / `Event` / `CustomEvent` +/// (real `ObjectHeader`s whose instances are never linked to their +/// `.prototype` object via `object_static_prototype`, so the generic +/// own/inherited-property walk in `object_to_string_tag_property` can never +/// reach a tag installed there). +/// +/// Shared by `js_object_to_string`'s brand string and +/// `js_object_get_symbol_property`'s `x[Symbol.toStringTag]` own-property +/// read (`crate::symbol::get`), so the two can never disagree. +pub(crate) fn web_builtin_to_string_tag(value: f64) -> Option<&'static str> { + let bits = value.to_bits(); + if (bits >> 48) != 0x7FFD { + return None; + } + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::value::addr_class::is_small_handle(addr) { + // Web Fetch handle family — one shared id counter, disjoint registries + // (see `js_fetch_handle_kind`'s own doc comment). + if let Some(probe) = crate::object::fetch_handle_kind_probe() { + match unsafe { probe(addr) } { + 1 => return Some("Response"), + 2 => return Some("Request"), + 3 => return Some("Headers"), + 4 => return Some("Blob"), + 5 => return Some("File"), + 6 => return Some("FormData"), + _ => {} + } + } + // `TextEncoder` is a single stateless sentinel id; `TextDecoder` + // instances are `DECODER_REGISTRY` members. Neither overlaps the + // Web Fetch band (`FETCH_HANDLE_BAND_START` starts well above 2). + if addr == crate::text::TEXT_ENCODER_SENTINEL_ID as usize { + return Some("TextEncoder"); + } + if crate::text::is_known_text_decoder_id(addr as i64) { + return Some("TextDecoder"); + } + return None; + } + // #10555 lint: `is_valid_obj_ptr` alone is not a sufficient handle-band + // guard (its own doc says so -- the Linux/Android/iOS/Windows HEAP_MIN + // floor sits below the handle band). The `is_small_handle` branch above + // already excludes that band, but it is too far above this line for the + // addr-class ratchet's pairing window, so re-validate right here with + // `try_read_gc_header` -- the same idiom `is_url_object_shape` / + // `shape_is_url_search_params` already use for this exact receiver kind. + let obj = match unsafe { crate::value::addr_class::try_read_gc_header(addr) } { + Some(h) if h.obj_type == crate::gc::GC_TYPE_OBJECT => addr as *const ObjectHeader, + _ => return None, + }; + if crate::url::is_url_object_shape(obj as *mut ObjectHeader) { + return Some("URL"); + } + if crate::url::search_params::shape_is_url_search_params(obj) { + return Some("URLSearchParams"); + } + match unsafe { (*obj).class_id } { + crate::url::abort::ABORT_CONTROLLER_CLASS_ID => Some("AbortController"), + crate::url::abort::ABORT_SIGNAL_CLASS_ID => Some("AbortSignal"), + crate::event_target::CLASS_ID_EVENT_TARGET => Some("EventTarget"), + crate::event_target::CLASS_ID_EVENT => Some("Event"), + crate::event_target::CLASS_ID_CUSTOM_EVENT => Some("CustomEvent"), + _ => None, + } +} + unsafe fn string_value_to_owned(value: f64) -> Option { let jv = crate::value::JSValue::from_bits(value.to_bits()); if !jv.is_any_string() { @@ -257,6 +333,12 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 { let str_ptr = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); return f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK)); } + if let Some(tag) = web_builtin_to_string_tag(value) { + let formatted = format!("[object {}]", tag); + let bytes = formatted.as_bytes(); + let str_ptr = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + return f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK)); + } if let Some(tag) = crate::builtins::boxed_primitive_to_string_tag(value) { let formatted = format!("[object {}]", tag); let bytes = formatted.as_bytes(); diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index ed72157767..e79e5ba6df 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -518,6 +518,34 @@ unsafe fn web_stream_symbol_property(obj_f64: f64, sym_f64: f64) -> Option Some(f64::from_bits(TAG_UNDEFINED)) } +/// `Symbol.toStringTag` for Perry's Web/runtime built-ins that have no +/// registered prototype-chain or class-id hook reachable from the generic +/// resolvers below (#10555) -- see `web_builtin_to_string_tag`'s doc comment +/// for the full inventory and why each kind needs this. An own override +/// (`Object.defineProperty(x, Symbol.toStringTag, …)`) still wins: the +/// side-table read is a pointer-KEYED lookup, safe even for the +/// handle-backed kinds since it never dereferences `obj_f64` as a pointer. +unsafe fn web_builtin_to_string_tag_symbol_property(obj_f64: f64, sym_f64: f64) -> Option { + let sym_key = sym_key_from_f64(sym_f64); + if sym_key == 0 { + return None; + } + let to_string_tag = well_known_symbol("toStringTag"); + if to_string_tag.is_null() { + return None; + } + let ts_f64 = f64::from_bits(crate::value::JSValue::pointer(to_string_tag as *const u8).bits()); + if sym_key != sym_key_from_f64(ts_f64) { + return None; + } + if let Some(v) = own_symbol_property(obj_f64, sym_f64) { + return Some(v); + } + let tag = crate::object::web_builtin_to_string_tag(obj_f64)?; + let str_ptr = js_string_from_bytes(tag.as_ptr(), tag.len() as u32); + Some(f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK))) +} + #[no_mangle] pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f64) -> f64 { #[cfg(feature = "regex-engine")] @@ -668,6 +696,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if let Some(v) = web_stream_symbol_property(obj_f64, sym_f64) { return v; } + if let Some(v) = web_builtin_to_string_tag_symbol_property(obj_f64, sym_f64) { + return v; + } // #1213: Timeout/Immediate handles expose `Symbol.dispose` so // `using t = setTimeout(...)` and `t[Symbol.dispose]()` clear the timer. // The handle is a small id NaN-boxed as POINTER; the symbol-keyed read diff --git a/crates/perry-stdlib/src/fetch/body_metadata.rs b/crates/perry-stdlib/src/fetch/body_metadata.rs index 0695ff11ac..b66d7b6148 100644 --- a/crates/perry-stdlib/src/fetch/body_metadata.rs +++ b/crates/perry-stdlib/src/fetch/body_metadata.rs @@ -67,6 +67,15 @@ lazy_static::lazy_static! { static ref FORM_DATA_REGISTRY: Mutex> = Mutex::new(HashMap::new()); } +/// #10555: `instanceof FormData` / `Object.prototype.toString.call` / +/// `x[Symbol.toStringTag]` membership probe, mirroring `dispatch.rs`'s +/// `js_fetch_handle_kind` for the other Web Fetch handle kinds. Lives here +/// (not `dispatch.rs`) because `FORM_DATA_REGISTRY` is private to this +/// module; `dispatch.rs` reaches it via `super::body_metadata::…`. +pub(super) fn is_registered_form_data(id: usize) -> bool { + FORM_DATA_REGISTRY.lock().unwrap().contains_key(&id) +} + fn alloc_form_data(store: FormDataStore) -> usize { let id = alloc_fetch_handle_id(); FORM_DATA_REGISTRY.lock().unwrap().insert(id, store); diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index 2fc06a1a7c..f0de1b9aae 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -300,10 +300,14 @@ fn form_data_bound_method_value(form_id: usize, method_name: &'static str) -> f6 /// `instanceof` kind-probe for fetch handles (registered with the runtime at /// init via `js_register_fetch_handle_kind_probe`). Returns 0 = none, -/// 1 = Response, 2 = Request, 3 = Headers, 4 = Blob, 5 = File. Lets -/// `x instanceof Response` (etc.) resolve for the pointer-tagged small-integer -/// handles these types use instead of heap objects. Lives here (not `mod.rs`) -/// to keep that file under the 2,000-line lint gate. +/// 1 = Response, 2 = Request, 3 = Headers, 4 = Blob, 5 = File, 6 = FormData. +/// Lets `x instanceof Response` (etc.) resolve for the pointer-tagged +/// small-integer handles these types use instead of heap objects. #10555 +/// additionally reuses this for `Object.prototype.toString` / +/// `Symbol.toStringTag`; FormData (kind 6) is new here -- nothing previously +/// needed to tell it apart from the other fetch-family handles by id alone. +/// Lives here (not `mod.rs`) to keep that file under the 2,000-line lint +/// gate. #[no_mangle] pub extern "C" fn js_fetch_handle_kind(id: usize) -> u8 { if FETCH_RESPONSES.lock().unwrap().contains_key(&id) { @@ -318,6 +322,9 @@ pub extern "C" fn js_fetch_handle_kind(id: usize) -> u8 { if let Some(blob) = BLOB_REGISTRY.lock().unwrap().get(&id) { return if blob.file_name.is_some() { 5 } else { 4 }; } + if super::body_metadata::is_registered_form_data(id) { + return 6; + } 0 } diff --git a/test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts b/test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts new file mode 100644 index 0000000000..0e4ab97eb8 --- /dev/null +++ b/test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts @@ -0,0 +1,60 @@ +// #10555: Web/runtime built-ins lack `Symbol.toStringTag`, so +// `Object.prototype.toString.call(x)` falls through to the generic +// `[object Object]` and `x[Symbol.toStringTag]` reads back `undefined`. +// Covers the full set the issue names plus adjacent built-ins that share the +// same fix shape: URL, URLSearchParams, Headers, Request, Response, +// FormData, Blob, AbortController, AbortSignal, TextEncoder, TextDecoder, +// EventTarget, Event. (Map/Promise/ArrayBuffer/DataView are deliberately out +// of scope -- see the PR body.) + +function describe(name: string, ctor: any, value: unknown): void { + const tagString = Object.prototype.toString.call(value); + const ownTag = String((value as any)[Symbol.toStringTag]); + const desc = Object.getOwnPropertyDescriptor(ctor.prototype, Symbol.toStringTag); + console.log( + name, + tagString, + ownTag, + desc ? desc.value : "MISSING", + desc ? desc.writable : "MISSING", + desc ? desc.enumerable : "MISSING", + desc ? desc.configurable : "MISSING", + ); +} + +describe("URL", URL, new URL("http://x/")); +describe("URLSearchParams", URLSearchParams, new URLSearchParams("a=1")); +describe("Headers", Headers, new Headers()); +describe("Request", Request, new Request("http://x/")); +describe("Response", Response, new Response("x")); +describe("FormData", FormData, new FormData()); +describe("Blob", Blob, new Blob(["a"])); +describe("AbortController", AbortController, new AbortController()); +describe("AbortSignal", AbortSignal, new AbortController().signal); +describe("TextEncoder", TextEncoder, new TextEncoder()); +describe("TextDecoder", TextDecoder, new TextDecoder()); +describe("EventTarget", EventTarget, new EventTarget()); +describe("Event", Event, new Event("x")); + +// The tag must never leak into JSON serialization (symbol keys never +// serialize -- a true invariant, kept here as a non-regression canary). +const u = new URL("http://x/"); +console.log("json:", JSON.stringify({ tag: String(u[Symbol.toStringTag]) })); + +// `typeof` must be unaffected by the new property (a pre-existing, +// unrelated `instanceof` gap for the generic-class-id representations this +// fix's own doc comment describes is out of scope for #10555 -- see the PR +// body). +console.log( + "typeof:", + typeof u, + typeof new Headers(), + typeof new AbortController(), + typeof new EventTarget(), +); + +// The descriptor is non-writable: a direct `Reflect.set` on the prototype +// object itself (no inheritance walk involved) must report failure without +// throwing, and must not change the value. +console.log("reflect-set:", Reflect.set(URL.prototype, Symbol.toStringTag, "Nope")); +console.log("still URL:", String(u[Symbol.toStringTag])); From 28a5dd9e22f94a1d58b68e0ba370846096248ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 14:40:10 +0000 Subject: [PATCH 2/2] docs(changelog): add fragment for #10632 --- changelog.d/10632-symbol-tostringtag.md | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 changelog.d/10632-symbol-tostringtag.md diff --git a/changelog.d/10632-symbol-tostringtag.md b/changelog.d/10632-symbol-tostringtag.md new file mode 100644 index 0000000000..2486c690f8 --- /dev/null +++ b/changelog.d/10632-symbol-tostringtag.md @@ -0,0 +1,47 @@ +### Fixed + +- **Web/runtime built-ins lack `Symbol.toStringTag` (#10555).** + `Object.prototype.toString.call(new URLSearchParams())` was `[object + Object]` instead of `[object URLSearchParams]`, and `x[Symbol.toStringTag]` + read back `undefined` — the standard cross-realm type check `kindOf`, + `isURLSearchParams`, `isFormData`, `isBlob`, and lodash's `baseGetTag` use. + axios 1.19.0 decides how to serialize a request body this way: a + `URLSearchParams` body was sent as JSON instead of + `application/x-www-form-urlencoded`. + + Covers `URL`, `URLSearchParams`, `Headers`, `Request`, `Response`, + `FormData`, `Blob`, `File`, `AbortController`, `AbortSignal`, + `TextEncoder`, `TextDecoder`, `EventTarget`, `Event`, `CustomEvent` — both + the brand string and the real `x[Symbol.toStringTag]` value, plus a + correctly-shaped (`writable: false, enumerable: false, configurable: true`) + own descriptor on each constructor's `.prototype`. `Map`/`Promise`/ + `ArrayBuffer`/`DataView` are deliberately out of scope (their brand string + was already correct via a different, structural mechanism — only their own + property is missing, a separate fix). `Uint8Array` already had a correct + accessor. + + Root cause: two representations, two gaps. The Web Fetch family and + `TextEncoder`/`TextDecoder` are small-integer registry handles with no + brand/property case in `js_object_to_string` or + `js_object_get_symbol_property`. `URL`/`URLSearchParams` and + `AbortController`/`AbortSignal`/`EventTarget`/`Event`/`CustomEvent` are + real objects whose instances are never `[[Prototype]]`-linked to their + `.prototype` object, so a property installed only there (the issue's + suggested shape) would never be reached from an instance. + + Fix: a new `web_builtin_to_string_tag` in `perry-runtime` answers both + `Object.prototype.toString` and `x[Symbol.toStringTag]` from one place + (reusing the existing `fetch_handle_kind_probe`/structural/class-id + detectors), and a real descriptor is *also* installed on each + constructor's `.prototype` for reflection. The directly-affected path + measured ~4.7x fewer instructions, not slower (the new check runs early + and short-circuits several later brand checks a `Headers` handle used to + fall through). + + Validation: new `test_gap_10555_symbol_tostringtag_web_builtins` (brand + string, property value, and full descriptor shape for all 13 types, plus + JSON/typeof/non-writability checks) fails on the baseline and matches Node + on the fix. Two existing unit tests updated (not a regression — the fix + makes `TextEncoder`'s sentinel handle id meaningful, which one test had + assumed was generic); `test_gap_url*`/`test_gap_headers*`/ + `test_gap_fetch*`/`test_gap_events_import_4995` (12 tests) unaffected.