diff --git a/changelog.d/9795-string-code-point-at-dispatch.md b/changelog.d/9795-string-code-point-at-dispatch.md new file mode 100644 index 0000000000..04cde8e0c0 --- /dev/null +++ b/changelog.d/9795-string-code-point-at-dispatch.md @@ -0,0 +1,12 @@ +### Runtime + +- perf(runtime): `String.prototype.codePointAt` is answered by the native + string-method dispatch instead of falling through to the primitive-method + fallback. It had a prototype thunk but no dispatch arm, so every call + resolved `globalThis.String.prototype.codePointAt`, cloned that closure to + rebind `this`, and — the thunk not being registered strict — ran `ToObject` + on the receiver, minting a `String` wrapper with an own index property per + UTF-16 code unit. Grapheme-aware text measurement calls it once per + character: on the compiled claude-code TUI it was the only method name + reaching the fallback, at 99,008 calls and 99,008 wrappers per 400-character + streamed reply (`PERRY_GC_DIAG=1`, `[gc-primitive-dispatch]`). diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index f05a2285a6..c7452709a7 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -18,6 +18,8 @@ mod primitive_methods; mod proto_dispatch; mod string_methods; +#[cfg(test)] +mod code_point_at_dispatch_tests; #[cfg(test)] mod dispatch_arg_coercion_tests; #[cfg(test)] diff --git a/crates/perry-runtime/src/object/native_call_method/code_point_at_dispatch_tests.rs b/crates/perry-runtime/src/object/native_call_method/code_point_at_dispatch_tests.rs new file mode 100644 index 0000000000..e1ce0b8cfc --- /dev/null +++ b/crates/perry-runtime/src/object/native_call_method/code_point_at_dispatch_tests.rs @@ -0,0 +1,80 @@ +//! #9761: `String.prototype.codePointAt` must be answered by the native +//! string-method dispatch, not by the primitive-method FALLBACK. +//! +//! The fallback (`call_primitive_builtin_prototype_method`) resolves +//! `globalThis.String.prototype[]`, clones that closure to rebind `this`, +//! and — because the resolved thunk is not registered strict — runs `ToObject` +//! on the receiver, minting a `String` wrapper with an own index property per +//! UTF-16 code unit. `codePointAt` had a prototype thunk but no dispatch arm, +//! and grapheme-aware text measurement calls it once per character: on the +//! compiled claude-code TUI it was the ONLY name reaching the fallback, at +//! 99,008 calls (and 99,008 wrappers) per 400-character streamed reply. +//! +//! The assertion is the wrapper count, not the return value: a test that only +//! checked the answer would pass with the arm deleted, because the fallback +//! computes the same number — expensively. `BOXED_PRIMITIVE_PAYLOADS` gains one +//! entry per wrapper, so "no new boxed primitives" is exactly "the fallback did +//! not run". + +use crate::value::JSValue; + +unsafe fn call_string_method(receiver: &str, method: &str, args: &[f64]) -> f64 { + let s = crate::string::js_string_from_bytes(receiver.as_ptr(), receiver.len() as u32); + let recv = f64::from_bits(JSValue::string_ptr(s).bits()); + super::js_native_call_method( + recv, + method.as_ptr() as *const i8, + method.len(), + if args.is_empty() { + std::ptr::null() + } else { + args.as_ptr() + }, + args.len(), + ) +} + +#[test] +fn code_point_at_dispatches_natively_and_boxes_no_receiver() { + unsafe { + // Warm anything the first dispatch installs, so the delta below is the + // method call itself and not one-time globalThis population. + let _ = call_string_method("ab", "charCodeAt", &[0.0]); + let before = crate::builtins::test_boxed_primitive_payload_count(); + + let cp = call_string_method("a", "codePointAt", &[0.0]); + assert_eq!(cp, 97.0, "codePointAt(0) of \"a\""); + let astral = call_string_method("\u{1F600}b", "codePointAt", &[0.0]); + assert_eq!(astral, 128512.0, "an astral pair is one code point"); + let past_end = call_string_method("a", "codePointAt", &[5.0]); + assert!( + JSValue::from_bits(past_end.to_bits()).is_undefined(), + "out of range is undefined" + ); + + assert_eq!( + crate::builtins::test_boxed_primitive_payload_count(), + before, + "the native arm must not mint a String wrapper; a non-zero delta \ + means the call fell through to the primitive-method fallback" + ); + } +} + +/// Positive control for the assertion above: the counter must be able to move, +/// or "no new boxed primitives" proves nothing. Minting the wrapper the +/// fallback would have minted is the direct, environment-independent form — +/// a second dispatch through a name without a native arm cannot serve as the +/// control here, because the unit-test thread has no populated `globalThis` +/// and the fallback returns before it boxes. +#[test] +fn the_wrapper_counter_moves_when_a_receiver_is_boxed() { + let before = crate::builtins::test_boxed_primitive_payload_count(); + let s = crate::string::js_string_from_bytes(b"abc".as_ptr(), 3); + let value = f64::from_bits(JSValue::string_ptr(s).bits()); + let _wrapper = crate::builtins::js_boxed_string_new(value, 1); + assert!( + crate::builtins::test_boxed_primitive_payload_count() > before, + "if this cannot move, the codePointAt assertion above is vacuous" + ); +} diff --git a/crates/perry-runtime/src/object/native_call_method/string_methods.rs b/crates/perry-runtime/src/object/native_call_method/string_methods.rs index cae30b6a76..d6213dee14 100644 --- a/crates/perry-runtime/src/object/native_call_method/string_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/string_methods.rs @@ -136,6 +136,20 @@ pub(super) unsafe fn dispatch_string( "charCodeAt" => { return Some(crate::string::js_string_char_code_at(s_ptr, arg_i32(0))); } + // #9761: `codePointAt` had a `String.prototype` thunk but no + // arm here, so it was the ONE method name the compiled + // claude-code TUI drove into the primitive-method FALLBACK: + // 99,008 calls per 400-character reply, each of which looked + // `globalThis.String` up, cloned the prototype closure to + // rebind `this`, and — because the callee is sloppy — ran + // `ToObject` on the receiver, minting a `String` wrapper with + // its own index property. Grapheme-aware text measurement + // calls it once per character, so a missing arm here is a + // per-character wrapper. It is the sibling of `charCodeAt` + // one line up and reads the same receiver the same way. + "codePointAt" => { + return Some(crate::string::js_string_code_point_at(s_ptr, arg_i32(0))); + } "slice" => { // Coerce args first (`arg_i32` may run user `valueOf` and move // the receiver under GC), then re-fetch the rooted receiver.