From fa3276aebdf32e1cf69ea900221c707e142b1374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:28:48 +0000 Subject: [PATCH 01/23] fix(runtime): cache Web Crypto method closures instead of reallocating per read globalThis.crypto.randomUUID/getRandomValues and crypto.subtle's KEM methods (encapsulateBits/decapsulateBits/encapsulateKey/decapsulateKey) allocated a fresh closure on every property read via plain js_closure_alloc, so the method had no stable identity (crypto.randomUUID === crypto.randomUUID was false) and every read allocated. Use the existing func-ptr-keyed js_closure_alloc_singleton cache instead, matching how other builtin methods stay identity-stable across reads. --- .../src/object/global_this/ctor_thunks.rs | 18 +++++- ...est_gap_10427_webcrypto_method_identity.ts | 61 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_10427_webcrypto_method_identity.ts diff --git a/crates/perry-runtime/src/object/global_this/ctor_thunks.rs b/crates/perry-runtime/src/object/global_this/ctor_thunks.rs index 2468db130b..cb50412369 100644 --- a/crates/perry-runtime/src/object/global_this/ctor_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/ctor_thunks.rs @@ -382,6 +382,16 @@ pub(crate) extern "C" fn cryptokey_usages_getter_thunk( cryptokey_property_getter(b"usages") } +/// #10427: `globalThis.crypto.` is a property READ, resolved fresh +/// on every access through `vt_get_own_field` (there is no real `ObjectHeader` +/// backing `globalThis.crypto` for the read to land an own slot on — see +/// `crypto.webcrypto`'s NATIVE_MODULE_CLASS_ID namespace). Plain +/// `js_closure_alloc` mints a brand-new `ClosureHeader` on every call, so +/// `crypto.randomUUID === crypto.randomUUID` was `false` and every read +/// allocated. `js_closure_alloc_singleton` (the same func-ptr-keyed cache PR +/// #10630 traced the closure-identity contract back to) returns the SAME +/// closure for the same `func_ptr` every time — the func_ptr IS the method +/// identity here since these thunks take no captures. pub(crate) fn webcrypto_method_value(property_name: &str) -> Option { let (func_ptr, arity) = match property_name { "getRandomValues" => (webcrypto_get_random_values_thunk as *const u8, 1), @@ -389,7 +399,7 @@ pub(crate) fn webcrypto_method_value(property_name: &str) -> Option { _ => return None, }; crate::closure::js_register_closure_arity(func_ptr, arity); - let closure = crate::closure::js_closure_alloc(func_ptr, 0); + let closure = crate::closure::js_closure_alloc_singleton(func_ptr); if closure.is_null() { return Some(f64::from_bits(crate::value::TAG_UNDEFINED)); } @@ -408,10 +418,14 @@ fn subtle_crypto_method_spec(property_name: &str) -> Option<(*const u8, u32)> { } } +/// Same per-read allocation defect as `webcrypto_method_value` above, for +/// `crypto.subtle`'s KEM methods (`encapsulateBits` and friends — the rest of +/// SubtleCrypto's surface is already cached via `bound_native_callable_export_value`, +/// see #10427's PR body for which paths were and weren't affected). pub(crate) fn subtle_crypto_method_value(property_name: &str) -> Option { let (func_ptr, length) = subtle_crypto_method_spec(property_name)?; crate::closure::js_register_closure_rest(func_ptr, 0); - let closure = crate::closure::js_closure_alloc(func_ptr, 0); + let closure = crate::closure::js_closure_alloc_singleton(func_ptr); if closure.is_null() { return Some(f64::from_bits(crate::value::TAG_UNDEFINED)); } diff --git a/test-files/test_gap_10427_webcrypto_method_identity.ts b/test-files/test_gap_10427_webcrypto_method_identity.ts new file mode 100644 index 0000000000..5571479eb6 --- /dev/null +++ b/test-files/test_gap_10427_webcrypto_method_identity.ts @@ -0,0 +1,61 @@ +// #10427: reading a Web Crypto method off `globalThis.crypto` (and +// `crypto.subtle`) allocated a fresh closure on every access, so the method +// had no stable identity (`crypto.randomUUID === crypto.randomUUID` was +// `false`) and every read allocated. Root cause: `webcrypto_method_value` / +// `subtle_crypto_method_value` in +// crates/perry-runtime/src/object/global_this/ctor_thunks.rs called plain +// `js_closure_alloc` instead of the func-ptr-keyed `js_closure_alloc_singleton` +// cache. This covers every member of `globalThis.crypto` (`randomUUID`, +// `getRandomValues`, `subtle` itself, and `subtle`'s methods including the +// KEM pair that shared the same defect) plus `node:crypto`'s default import +// as an already-correct control. +import nodeCrypto from "node:crypto"; +import { randomBytes as namedRandomBytes, randomUUID as namedRandomUUID } from "node:crypto"; + +// The issue's own repro, verbatim. +console.log("randomUUID stable:", globalThis.crypto.randomUUID === globalThis.crypto.randomUUID); +console.log("node:crypto randomUUID stable:", nodeCrypto.randomUUID === nodeCrypto.randomUUID); +const seen = new Set(); +for (let i = 0; i < 3; i++) seen.add(globalThis.crypto.randomUUID); +console.log("seen.size:", seen.size); + +// The other Web Crypto members the issue asked to check. +console.log("getRandomValues stable:", globalThis.crypto.getRandomValues === globalThis.crypto.getRandomValues); +console.log("crypto namespace stable:", globalThis.crypto === globalThis.crypto); +console.log("subtle namespace stable:", globalThis.crypto.subtle === globalThis.crypto.subtle); +console.log("crypto.subtle === crypto.subtle (2nd read pair):", crypto.subtle === crypto.subtle); + +// crypto.subtle's KEM methods went through the same broken per-read thunk as +// randomUUID/getRandomValues. +console.log("subtle.encapsulateBits stable:", crypto.subtle.encapsulateBits === crypto.subtle.encapsulateBits); +console.log("subtle.decapsulateBits stable:", crypto.subtle.decapsulateBits === crypto.subtle.decapsulateBits); +console.log("subtle.encapsulateKey stable:", crypto.subtle.encapsulateKey === crypto.subtle.encapsulateKey); +console.log("subtle.decapsulateKey stable:", crypto.subtle.decapsulateKey === crypto.subtle.decapsulateKey); + +// Controls: subtle's other methods were already cached via a different +// mechanism (bound_native_callable_export_value) — must stay stable too. +console.log("subtle.digest stable:", crypto.subtle.digest === crypto.subtle.digest); +console.log("subtle.encrypt stable:", crypto.subtle.encrypt === crypto.subtle.encrypt); +console.log("subtle.generateKey stable:", crypto.subtle.generateKey === crypto.subtle.generateKey); + +// Cross-read identity: the SAME closure across DIFFERENT expressions that +// resolve to the same property, not just repeated reads of one expression. +const a = globalThis.crypto.randomUUID; +const b = crypto.randomUUID; +console.log("cross-read identity:", a === b); + +// node:crypto (module-level) default vs named import identity — already +// correct before this fix; kept as a control so a future regression there +// shows up in the same test. +console.log("node:crypto default randomUUID === named randomUUID:", nodeCrypto.randomUUID === namedRandomUUID); +console.log("node:crypto randomBytes stable:", nodeCrypto.randomBytes === nodeCrypto.randomBytes); +console.log("node:crypto named randomBytes === default randomBytes:", namedRandomBytes === nodeCrypto.randomBytes); + +// Functional sanity: the cached closure still WORKS (shape check only — the +// value itself is random, so no exact value is printed). +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const u = globalThis.crypto.randomUUID(); +console.log("randomUUID() shape ok:", uuidPattern.test(u)); +console.log("randomUUID() distinct across calls:", globalThis.crypto.randomUUID() !== globalThis.crypto.randomUUID()); +const bytes = globalThis.crypto.getRandomValues(new Uint8Array(8)); +console.log("getRandomValues() length ok:", bytes.length === 8); From 7aaf8620ca171ebe8540a2fcec59a421107887da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:30:31 +0000 Subject: [PATCH 02/23] docs(changelog): fragment for the Web Crypto method identity fix (#10643) --- changelog.d/10643-webcrypto-method-identity.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10643-webcrypto-method-identity.md diff --git a/changelog.d/10643-webcrypto-method-identity.md b/changelog.d/10643-webcrypto-method-identity.md new file mode 100644 index 0000000000..2a127a7cbf --- /dev/null +++ b/changelog.d/10643-webcrypto-method-identity.md @@ -0,0 +1,3 @@ +### Fixed + +- `globalThis.crypto.randomUUID`, `.getRandomValues`, and `crypto.subtle`'s KEM methods (`encapsulateBits`/`decapsulateBits`/`encapsulateKey`/`decapsulateKey`) now have a stable identity across reads (`crypto.randomUUID === crypto.randomUUID` is `true`) instead of allocating a fresh closure on every property access. From ee673de9a62d86b0f976b24fc38fc9084a6f355c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:29:11 +0000 Subject: [PATCH 03/23] fix(runtime): fix Buffer.prototype's own-property shape Buffer.prototype had 36 bogus own properties (including bare string literals "function"/"undefined" that had drifted in from nearby prose/JS-idiom comments, plus DataView/Uint8Array.prototype/ Object.prototype methods that belong further up the prototype chain) and was missing 16 of Node's own members: the 14 internal Slice/Write methods and the deprecated offset/ parent accessors. Root cause: BUFFER_PROTOTYPE_METHODS (which populates Buffer.prototype's own enumerable properties) was generated from the SAME name table buffer_dispatch::is_buffer_method_name uses to decide whether a property read on a Buffer INSTANCE should synthesize a bound-method closure - deliberately broad, for duck-typed inherited-method reads - conflating that broad instance-read predicate with the narrow set of names that should be Buffer.prototype's own keys. Decoupled the two: curated BUFFER_PROTOTYPE_METHODS down to Node's real 96-name own-property surface (removing the 36 bogus entries, adding the 14 internal Slice/Write methods with real dispatch behavior, and adding offset/parent as accessor descriptors), while leaving is_buffer_method_name's broader instance-read predicate untouched. Also fixed a dormant gap the new ucs2Write method depends on: js_buffer_write_len's encoding match never handled tag 6 (utf16le/ ucs2), silently writing raw UTF-8 bytes instead - a pre-existing defect in buf.write(str, offset, 'utf16le') too. --- crates/perry-runtime/src/buffer/copy_write.rs | 6 + crates/perry-runtime/src/buffer/from.rs | 6 +- .../src/object/buffer_dispatch.rs | 73 ++++++++ .../object/native_module/callable_exports.rs | 172 +++++++++++++----- .../test_gap_10426_buffer_prototype_shape.ts | 163 +++++++++++++++++ 5 files changed, 376 insertions(+), 44 deletions(-) create mode 100644 test-files/test_gap_10426_buffer_prototype_shape.ts diff --git a/crates/perry-runtime/src/buffer/copy_write.rs b/crates/perry-runtime/src/buffer/copy_write.rs index 4fbed786f1..20315d64b8 100644 --- a/crates/perry-runtime/src/buffer/copy_write.rs +++ b/crates/perry-runtime/src/buffer/copy_write.rs @@ -105,6 +105,12 @@ pub extern "C" fn js_buffer_write_len( let bytes_to_write = match encoding { 1 => decode_hex(str_bytes), 2 | 3 => decode_base64(str_bytes), + // #10426: encoding tag 6 (utf16le/ucs2) fell through to the + // default (raw UTF-8 bytes) arm — dormant in the pre-existing + // `buf.write(str, offset, 'utf16le')` path and would have made + // the new `ucs2Write` method equally wrong. `from::utf16le_string_bytes` + // is the same UTF-16LE encoder `Buffer.from(str, 'utf16le')` uses. + 6 => super::from::utf16le_string_bytes(str_bytes), _ => str_bytes.to_vec(), }; diff --git a/crates/perry-runtime/src/buffer/from.rs b/crates/perry-runtime/src/buffer/from.rs index 16ed61c8dd..50725ce64d 100644 --- a/crates/perry-runtime/src/buffer/from.rs +++ b/crates/perry-runtime/src/buffer/from.rs @@ -110,7 +110,11 @@ fn latin1_string_bytes(str_bytes: &[u8]) -> Vec { out } -fn utf16le_string_bytes(str_bytes: &[u8]) -> Vec { +/// #10426: made `pub(crate)` so `copy_write.rs`'s `js_buffer_write_len` +/// can reuse it for the new `ucs2Write` method (and to close the same gap +/// in the pre-existing generic `buf.write(str, offset, 'utf16le')` path, +/// which silently wrote raw UTF-8 bytes instead of UTF-16LE before this). +pub(crate) fn utf16le_string_bytes(str_bytes: &[u8]) -> Vec { let decoded = String::from_utf8_lossy(str_bytes); let mut out = Vec::with_capacity(decoded.len() * 2); for unit in decoded.encode_utf16() { diff --git a/crates/perry-runtime/src/object/buffer_dispatch.rs b/crates/perry-runtime/src/object/buffer_dispatch.rs index ae1fcc2a72..0e5a90280c 100644 --- a/crates/perry-runtime/src/object/buffer_dispatch.rs +++ b/crates/perry-runtime/src/object/buffer_dispatch.rs @@ -243,8 +243,43 @@ buffer_method_names!( "getBigUint64", "setBigInt64", "setBigUint64", + // #10426: Node's internal fixed-encoding slice/write pair, present as + // real own methods on `Buffer.prototype` (`buf.write(str, off, enc)` / + // `buf.toString(enc, start, end)` dispatch through these internally in + // Node; Perry exposes the same names so duck-typed reads and direct + // calls both work, matching `Object.getOwnPropertyNames(Buffer.prototype)`). + "asciiSlice", + "asciiWrite", + "base64Slice", + "base64Write", + "base64urlSlice", + "base64urlWrite", + "hexSlice", + "hexWrite", + "latin1Slice", + "latin1Write", + "ucs2Slice", + "ucs2Write", + "utf8Slice", + "utf8Write", ); +/// Fixed encoding tag for one of Node's internal `Buffer.prototype` +/// `Slice`/`Write` methods (#10426). Tags match +/// `js_encoding_tag_from_value`'s numbering (0=utf8 … 6=utf16le/ucs2). +fn fixed_slice_write_encoding(method_name: &str) -> Option { + Some(match method_name { + "utf8Slice" | "utf8Write" => 0, + "hexSlice" | "hexWrite" => 1, + "base64Slice" | "base64Write" => 2, + "base64urlSlice" | "base64urlWrite" => 3, + "latin1Slice" | "latin1Write" => 4, + "asciiSlice" | "asciiWrite" => 5, + "ucs2Slice" | "ucs2Write" => 6, + _ => return None, + }) +} + unsafe fn buffer_secret_export_format(bits: f64) -> Option { let raw = bits.to_bits(); if (raw >> 48) as u16 == 0x7FFC { @@ -680,6 +715,44 @@ pub unsafe fn dispatch_buffer_method( crate::buffer::js_buffer_copy(buf_ptr, dst_ptr, target_start, source_start, source_end) as f64 } + // #10426: Node's internal `Slice(start, end)` / + // `Write(string, offset, length)` pair — the same + // operation as `toString(encoding, start, end)` / `write(string, + // offset, length, encoding)` with the encoding fixed by the method + // name instead of an argument. + "asciiSlice" | "base64Slice" | "base64urlSlice" | "hexSlice" | "latin1Slice" + | "ucs2Slice" | "utf8Slice" => { + let enc = fixed_slice_write_encoding(method_name).unwrap_or(0); + let len = (*buf_ptr).length as i32; + let start = if !args.is_empty() { arg_i32(0) } else { 0 }; + let end = if args.len() >= 2 { arg_i32(1) } else { len }; + let str_ptr = crate::buffer::js_buffer_to_string_range(buf_ptr, enc, start, end); + f64::from_bits(JSValue::string_ptr(str_ptr).bits()) + } + "asciiWrite" | "base64Write" | "base64urlWrite" | "hexWrite" | "latin1Write" + | "ucs2Write" | "utf8Write" => { + if args.is_empty() || !is_buffer_dispatch_string(args[0]) { + throw_buffer_type_error_with_code( + "argument must be a string", + "ERR_INVALID_ARG_TYPE", + ); + } + let enc = fixed_slice_write_encoding(method_name).unwrap_or(0); + let str_bits = args[0].to_bits(); + let str_addr = if (str_bits >> 48) >= 0x7FF8 { + str_bits & 0x0000_FFFF_FFFF_FFFF + } else { + str_bits + }; + let str_ptr = str_addr as *const crate::string::StringHeader; + let offset = if args.len() >= 2 { arg_i32(1) } else { 0 }; + let max_len = if args.len() >= 3 { + arg_i32(2) + } else { + (*buf_ptr).length as i32 - offset + }; + crate::buffer::js_buffer_write_len(buf_ptr, str_ptr, offset, max_len, enc) as f64 + } "toJSON" => crate::buffer::js_buffer_to_json(buf_f64), // `buf.write(string, offset?, length?, encoding?)` — writes the // utf8/hex/base64 encoding of `string` into `buf` at `offset`. diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 73fc029d8f..dfa21cbac7 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -426,6 +426,83 @@ extern "C" fn buffer_prototype_method_thunk(_closure: *const crate::closure::Clo f64::from_bits(crate::value::TAG_UNDEFINED) } +/// `this` for a `Buffer.prototype.offset`/`.parent` accessor read, resolved +/// through `IMPLICIT_THIS` (the same mechanism `require_webcrypto_this` in +/// `ctor_thunks.rs` uses for Web Crypto getters). `None` for a non-buffer +/// receiver — Node's real getters answer `undefined` rather than throwing +/// (`isInstance(this, Buffer) ? … : undefined`), and ordinary Buffer/typed- +/// array instance reads never reach this getter at all (they resolve +/// `.offset`/`.parent` directly — see `get_field_by_name_tail.rs`); this only +/// matters for reflection (`Object.getOwnPropertyDescriptor(Buffer.prototype, +/// "offset").get.call(x)`) and enumeration. +fn buffer_prototype_this_addr() -> Option { + let this_bits = crate::object::IMPLICIT_THIS.with(|c| c.get()); + let jv = crate::value::JSValue::from_bits(this_bits); + if !jv.is_pointer() { + return None; + } + let addr = (this_bits & crate::value::POINTER_MASK) as usize; + if addr == 0 || crate::buffer::js_buffer_is_buffer(addr as i64) == 0 { + return None; + } + Some(addr) +} + +/// #10426: `Buffer.prototype.parent` — deprecated legacy alias for +/// `.buffer` (the backing `ArrayBuffer`), still a real own accessor on +/// Node's `Buffer.prototype`. +extern "C" fn buffer_prototype_parent_getter_thunk( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + match buffer_prototype_this_addr() { + Some(addr) => { + crate::value::js_nanbox_pointer(crate::buffer::buffer_backing_array_buffer(addr) as i64) + } + None => f64::from_bits(crate::value::TAG_UNDEFINED), + } +} + +/// #10426: `Buffer.prototype.offset` — deprecated legacy alias for +/// `.byteOffset`, still a real own accessor on Node's `Buffer.prototype`. +extern "C" fn buffer_prototype_offset_getter_thunk( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + match buffer_prototype_this_addr() { + Some(addr) => crate::buffer::buffer_byte_offset(addr) as f64, + None => f64::from_bits(crate::value::TAG_UNDEFINED), + } +} + +/// Install a `Buffer.prototype` accessor (`offset`/`parent`) — `{ +/// enumerable: true, configurable: false }`, matching Node's +/// `ObjectDefineProperty(Buffer.prototype, name, { enumerable: true, get() +/// {…} })` (no `configurable: true`, so it defaults false). Mirrors +/// `install_webcrypto_proto_getter`'s shape for a `*mut ObjectHeader` proto. +fn install_buffer_prototype_getter(proto_obj: *mut ObjectHeader, name: &str, func_ptr: *const u8) { + if proto_obj.is_null() { + return; + } + crate::closure::js_register_closure_arity(func_ptr, 0); + let closure = crate::closure::js_closure_alloc(func_ptr, 0); + let value = if closure.is_null() { + f64::from_bits(crate::value::TAG_UNDEFINED) + } else { + set_bound_native_closure_name(closure, &format!("get {name}")); + crate::value::js_nanbox_pointer(closure as i64) + }; + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + js_object_set_field_by_name(proto_obj, key, f64::from_bits(crate::value::TAG_UNDEFINED)); + super::set_builtin_accessor_descriptor( + proto_obj as usize, + name.to_string(), + super::AccessorDescriptor { + get: value.to_bits(), + set: 0, + }, + super::PropertyAttrs::new(true, true, false), + ); +} + const BUFFER_STATIC_METHODS: &[&str] = &[ "from", "alloc", @@ -440,22 +517,40 @@ const BUFFER_STATIC_METHODS: &[&str] = &[ "copyBytesFrom", ]; -/// Node exposes the WHOLE Buffer method surface on `Buffer.prototype`, and it is -/// enumerable — `for (const k in Buffer.prototype)` yields ~93 names there. -/// Perry used to install ELEVEN, which quietly broke any code that walks the -/// prototype: mysql2 sizes every outgoing packet by no-op'ing the write methods -/// of a zero-length Buffer +/// Node's actual `Buffer.prototype` own-property surface — checked +/// property-by-property against `Object.getOwnPropertyNames(Buffer.prototype)` +/// on Node 26.5.1 (96 names there; 93 real callable methods here, plus +/// `constructor` and the `offset`/`parent` accessors installed separately +/// below = 96). Perry used to install ELEVEN, which quietly broke any code +/// that walks the prototype: mysql2 sizes every outgoing packet by no-op'ing +/// the write methods of a zero-length Buffer /// (`for (const k in Buffer.prototype) if (typeof mock[k] === "function") mock[k] = noop`), /// so `writeUInt32LE` — absent from the stub list — stayed live, wrote into the /// empty measuring buffer, and killed the MySQL handshake with -/// RangeError [ERR_OUT_OF_RANGE]. Generated from the dispatcher's own -/// `is_buffer_method_name` table so the two can't drift. +/// RangeError [ERR_OUT_OF_RANGE]. +/// +/// #10426: this list is DELIBERATELY NOT the same as +/// `buffer_dispatch::is_buffer_method_name` (a comment here used to say it +/// was "generated from" that table "so the two can't drift" — that coupling +/// was the bug). `is_buffer_method_name` answers a different question — "does +/// a property read on a Buffer *instance* need to synthesize a bound-method +/// closure" — and is deliberately broad: it also recognizes names Buffer +/// instances answer only by INHERITANCE (`Uint8Array.prototype.at`/`set`/ +/// `entries`/`keys`/`values`/`copyWithin`/`toBase64`/`toHex`/`setFromBase64`/ +/// `setFromHex`), by DataView accessors on a DataView-marked buffer +/// (`getInt32`/`setFloat64`/…), and by `Object.prototype` +/// (`hasOwnProperty`/`isPrototypeOf`/`propertyIsEnumerable`/`valueOf`) so +/// duck-type probes on an INSTANCE keep working (see that table's own +/// comments). None of those belong on `Buffer.prototype` itself as OWN +/// properties — Node inherits them further up the chain — so installing this +/// list from that one copied 36 names Node never puts here (plus two bare +/// string literals, `"function"` and `"undefined"`, that had drifted in from +/// nearby prose/JS-idiom comments and were never real method names at all). const BUFFER_PROTOTYPE_METHODS: &[&str] = &[ "toString", "inspect", "slice", "subarray", - "set", "copy", "write", "toJSON", @@ -465,18 +560,9 @@ const BUFFER_PROTOTYPE_METHODS: &[&str] = &[ "indexOf", "lastIndexOf", "includes", - "at", "swap16", "swap32", "swap64", - "values", - "keys", - "entries", - "undefined", - "hasOwnProperty", - "propertyIsEnumerable", - "valueOf", - "isPrototypeOf", "toLocaleString", "readUInt8", "readUint8", @@ -540,32 +626,20 @@ const BUFFER_PROTOTYPE_METHODS: &[&str] = &[ "writeUintLE", "writeIntBE", "writeIntLE", - "toBase64", - "toHex", - "setFromBase64", - "setFromHex", - "copyWithin", - "function", - "getInt8", - "getUint8", - "getInt16", - "getUint16", - "getInt32", - "getUint32", - "getFloat32", - "getFloat64", - "setInt8", - "setUint8", - "setInt16", - "setUint16", - "setInt32", - "setUint32", - "setFloat32", - "setFloat64", - "getBigInt64", - "getBigUint64", - "setBigInt64", - "setBigUint64", + "asciiSlice", + "asciiWrite", + "base64Slice", + "base64Write", + "base64urlSlice", + "base64urlWrite", + "hexSlice", + "hexWrite", + "latin1Slice", + "latin1Write", + "ucs2Slice", + "ucs2Write", + "utf8Slice", + "utf8Write", ]; const SQLITE_DATABASE_SYNC_PROTOTYPE_METHODS: &[&str] = &[ @@ -950,6 +1024,18 @@ pub(crate) fn buffer_constructor_value() -> f64 { }) }); } + proto.with_mut_ptr(|proto: *mut ObjectHeader| { + install_buffer_prototype_getter( + proto, + "parent", + buffer_prototype_parent_getter_thunk as *const u8, + ); + install_buffer_prototype_getter( + proto, + "offset", + buffer_prototype_offset_getter_thunk as *const u8, + ); + }); let proto_value = proto.with_mut_ptr(|proto: *mut ObjectHeader| { crate::value::js_nanbox_pointer(proto as i64) }); diff --git a/test-files/test_gap_10426_buffer_prototype_shape.ts b/test-files/test_gap_10426_buffer_prototype_shape.ts new file mode 100644 index 0000000000..dda95f3468 --- /dev/null +++ b/test-files/test_gap_10426_buffer_prototype_shape.ts @@ -0,0 +1,163 @@ +// #10426: `Buffer.prototype`'s own (enumerable) property set had 36 bogus +// entries — including two bare string literals ("function"/"undefined" that +// had drifted in from nearby prose/JS-idiom comments, never real method +// names), plus DataView accessors, `Uint8Array.prototype`/TC39 base64-hex +// methods, and `Object.prototype` methods that all belong further up the +// prototype chain, not as OWN properties of Buffer.prototype — and was +// missing 16 of Node's own members: the 14 internal `Slice`/ +// `Write` methods and the deprecated `offset`/`parent` accessors. +// Root cause: `BUFFER_PROTOTYPE_METHODS` in +// crates/perry-runtime/src/object/native_module/callable_exports.rs was +// generated from the SAME name table `buffer_dispatch::is_buffer_method_name` +// uses to decide whether a property READ on a Buffer *instance* should +// synthesize a bound-method closure (deliberately broad, for duck-typed +// inherited-method reads) — conflating that broad instance-read predicate +// with the narrow set of names that should be Buffer.prototype's own keys. +import { Buffer } from "node:buffer"; + +// The issue's own repro, verbatim (own/for-in counts + full sorted list). +const own = Object.getOwnPropertyNames(Buffer.prototype).sort(); +const forin: string[] = []; +for (const k in Buffer.prototype) forin.push(k); +console.log("own", own.length, "for-in", forin.length); +console.log("has 'function':", own.includes("function"), "has 'undefined':", own.includes("undefined")); +console.log(own.join(" ")); + +// Descriptor shape for a representative sample of each own-property kind: +// a non-enumerable data method (constructor), ordinary enumerable data +// methods (old + new), and the two new accessor properties. +function describe(name: string) { + const d = Object.getOwnPropertyDescriptor(Buffer.prototype, name); + if (!d) { + console.log(name, "MISSING"); + return; + } + console.log( + name, + JSON.stringify({ + writable: d.writable, + enumerable: d.enumerable, + configurable: d.configurable, + hasGet: typeof d.get === "function", + hasSet: typeof d.set === "function", + isFn: typeof d.value === "function", + }), + ); +} +for (const name of [ + "constructor", + "write", + "toString", + "toLocaleString", + "offset", + "parent", + "utf8Slice", + "utf8Write", + "hexSlice", + "hexWrite", + "base64Slice", + "base64urlSlice", + "asciiSlice", + "latin1Slice", + "ucs2Slice", +]) { + describe(name); +} + +// The bogus keys must be gone as OWN properties (still reachable as +// duck-typed INSTANCE reads through is_buffer_method_name, checked below — +// that's a distinct, intentionally-broader mechanism this fix doesn't touch). +console.log("own has 'at':", own.includes("at")); +console.log("own has 'set':", own.includes("set")); +console.log("own has 'hasOwnProperty':", own.includes("hasOwnProperty")); +console.log("own has 'toBase64':", own.includes("toBase64")); +console.log("own has 'getInt32':", own.includes("getInt32")); + +// mysql2's own motivating idiom: no-op every function-typed key found via +// for-in on Buffer.prototype, on a zero-length Buffer. +const mock = Buffer.alloc(0); +let noopCount = 0; +for (const k of forin) { + if (typeof (mock as any)[k] === "function") { + (mock as any)[k] = () => {}; + noopCount++; + } +} +console.log("noopCount:", noopCount); + +// Functional round-trips for the 14 newly-added internal methods, on a +// fixed, deterministic byte source ("Hello"). +const src = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); +console.log("utf8Slice:", src.utf8Slice(0, 5)); +console.log("asciiSlice:", src.asciiSlice(1, 4)); +console.log("latin1Slice:", src.latin1Slice(0, 5)); +console.log("hexSlice:", src.hexSlice(0, 5)); +console.log("base64Slice:", src.base64Slice(0, 5)); +console.log("base64urlSlice:", src.base64urlSlice(0, 5)); + +const u16 = Buffer.from("Hi", "utf16le"); +console.log("ucs2Slice:", u16.ucs2Slice(0, 4)); + +// base64url-distinguishing bytes (produces '+'/'/' in base64, '-'/'_' in +// base64url) round-tripped through both Slice and Write. +const urlish = Buffer.from([0xfb, 0xff, 0xbf]); +console.log("base64Slice (urlish):", urlish.base64Slice(0, 3)); +console.log("base64urlSlice (urlish):", urlish.base64urlSlice(0, 3)); + +const w1 = Buffer.alloc(8, 0); +console.log("utf8Write:", w1.utf8Write("Hi!", 1)); +console.log("utf8Write result:", w1.toString("hex")); + +const w2 = Buffer.alloc(8, 0); +console.log("hexWrite:", w2.hexWrite("48656c6c6f", 0)); +console.log("hexWrite result:", w2.toString("utf8", 0, 5)); + +const w3 = Buffer.alloc(8, 0); +console.log("base64Write:", w3.base64Write("SGVsbG8=", 0)); +console.log("base64Write result:", w3.toString("utf8", 0, 5)); + +const w4 = Buffer.alloc(8, 0); +console.log("base64urlWrite:", w4.base64urlWrite("SGVsbG8", 0)); +console.log("base64urlWrite result:", w4.toString("utf8", 0, 5)); + +const w5 = Buffer.alloc(8, 0); +console.log("asciiWrite:", w5.asciiWrite("Hi", 2)); +console.log("asciiWrite result:", w5.toString("ascii", 2, 4)); + +const w6 = Buffer.alloc(8, 0); +console.log("latin1Write:", w6.latin1Write("Hi", 0)); +console.log("latin1Write result:", w6.toString("latin1", 0, 2)); + +const w7 = Buffer.alloc(8, 0); +console.log("ucs2Write:", w7.ucs2Write("Hi", 0)); +console.log("ucs2Write result:", w7.toString("utf16le", 0, 4)); + +// Cross-check against the existing generic `toString(encoding, start, end)` +// / `write(string, offset, length, encoding)` paths these delegate to — must +// agree exactly. +console.log("utf8Slice === toString(utf8):", src.utf8Slice(0, 5) === src.toString("utf8", 0, 5)); +console.log("hexSlice === toString(hex):", src.hexSlice(0, 5) === src.toString("hex", 0, 5)); + +// `offset`/`parent` on a real instance (already correctly handled at the +// instance level before this fix — kept as a regression control) and a +// subarray with a non-zero byteOffset. +const backing = Buffer.alloc(16); +const view = backing.subarray(4, 10); +console.log("view.offset:", (view as any).offset, "view.byteOffset:", view.byteOffset); +console.log("view.parent === view.buffer:", (view as any).parent === view.buffer); + +// The new accessor's `get`, invoked directly off Buffer.prototype (the +// reflection path the accessor descriptor itself exists for). +const offsetGetter = Object.getOwnPropertyDescriptor(Buffer.prototype, "offset")!.get!; +console.log("offset getter via .call(view):", offsetGetter.call(view)); +console.log("offset getter via .call({}) (non-buffer this):", offsetGetter.call({})); + +// Regression: instance-level duck-typed reads for names removed from the +// OWN-property list must still work (inherited, not own). +const b = Buffer.alloc(4); +console.log("typeof b.hasOwnProperty:", typeof b.hasOwnProperty); +console.log("b.hasOwnProperty('x'):", b.hasOwnProperty("x")); +console.log("typeof b.at:", typeof b.at); +console.log("b.at(0):", b.at(0)); +console.log("Buffer.prototype.hasOwnProperty('at'):", Buffer.prototype.hasOwnProperty("at")); +console.log("Buffer.prototype.hasOwnProperty('hasOwnProperty'):", Buffer.prototype.hasOwnProperty("hasOwnProperty")); From ec264092e17e2e4fbc9347ffc3dcf6ab618f0303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:32:35 +0000 Subject: [PATCH 04/23] docs(changelog): fragment for the Buffer.prototype shape fix (#10644) --- changelog.d/10644-buffer-prototype-shape.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10644-buffer-prototype-shape.md diff --git a/changelog.d/10644-buffer-prototype-shape.md b/changelog.d/10644-buffer-prototype-shape.md new file mode 100644 index 0000000000..882862efac --- /dev/null +++ b/changelog.d/10644-buffer-prototype-shape.md @@ -0,0 +1,3 @@ +### Fixed + +- `Buffer.prototype`'s own property set no longer contains 36 bogus entries (including two bare string literals, `"function"` and `"undefined"`, plus `DataView`/`Uint8Array.prototype`/`Object.prototype` methods that belong further up the prototype chain) and now includes Node's internal `Slice`/`Write` methods and the deprecated `offset`/`parent` accessors, matching `Object.getOwnPropertyNames(Buffer.prototype)` on Node 26.5.1 exactly (96 own properties, 95 enumerable via `for-in`). From caeeb269bea7f15071054186666aade22269a52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:37:35 +0000 Subject: [PATCH 05/23] fix(runtime): materialize Object.prototype.__proto__ as a real accessor Object.prototype had no own __proto__ accessor, so hasOwnProperty, Object.hasOwn, getOwnPropertyNames, getOwnPropertyDescriptor, Reflect.ownKeys, and the in operator all disagreed with Node about it. Install a real { get, set, enumerable: false, configurable: true } accessor descriptor on Object.prototype (gate-neutral, so no dynamic property read/write fast path is affected), backed by the existing js_object_get_prototype_of / Annex B legacy setPrototypeOf logic. Also fixes a latent receiver-binding gap in primitive_builtin_prototype_property's inherited-property fallback, exposed by the new accessor: an accessor inherited transitively from Object.prototype through a primitive's builtin wrapper prototype (e.g. Number.prototype) was invoked with this bound to the intermediate prototype object instead of the original primitive receiver. --- .../src/object/field_get_set/accessors.rs | 18 +++ .../src/object/global_this/proto_methods.rs | 104 ++++++++++++++++++ crates/perry-runtime/src/proxy.rs | 51 ++++++--- 3 files changed, 158 insertions(+), 15 deletions(-) diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index cdaf2e0b5b..34c32c22e2 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -637,7 +637,25 @@ pub(crate) unsafe fn primitive_builtin_prototype_property( } } } + // #10482: the direct-accessor short-circuit just above only covers an + // accessor installed ON `proto_ptr` itself (`Number.prototype`). A key + // inherited from FURTHER up the chain — `Object.prototype.__proto__`, + // now a real accessor — resolves through this generic fallback instead, + // which recurses into `js_object_get_field_by_name(proto_ptr, key)`. + // That recursive walk finds the accessor on the ancestor and invokes it, + // but with no override in place it binds `this` to whichever prototype + // object the walk was probing (`Number.prototype`) rather than the + // original primitive `receiver` — so `(5).__proto__` was answering + // `Object.getPrototypeOf(Number.prototype)` (`Object.prototype`) instead + // of `Object.getPrototypeOf(5)` (`Number.prototype`). Stash the real + // receiver in the same thread-local override + // `resolve_inherited_field_from_prototype` uses for the identical + // problem one level up, so `invoke_accessor_getter` (reached from + // inside the recursive call) picks it up via `ACCESSOR_RECEIVER_OVERRIDE` + // instead of the prototype object it was handed. + let prev_override = accessor_receiver_override_begin(receiver); let value = js_object_get_field_by_name(proto_ptr, key); + accessor_receiver_override_end(prev_override); if value.is_undefined() { return None; } 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 fa3237e267..fa3f8c5231 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -106,6 +106,109 @@ fn install_array_iterator_symbol(proto_obj: *mut ObjectHeader, value: f64) { ); } +/// #10482: install `Object.prototype.__proto__` as a REAL accessor +/// descriptor — `{ get, set, enumerable: false, configurable: true }`, per +/// ECMA-262 Annex B §B.3.1 — instead of the purely behavioral special-casing +/// Perry had before (reads/writes worked through `__proto__` as a magic key +/// name in several call sites, but nothing on `Object.prototype` reflected +/// it). `hasOwnProperty`/`Object.hasOwn`/`getOwnPropertyNames`/ +/// `getOwnPropertyDescriptor`/`"__proto__" in {}`/`Reflect.ownKeys` all read +/// `ACCESSOR_DESCRIPTORS`/`PROPERTY_DESCRIPTORS` unconditionally, so a real +/// entry here is what makes them agree with Node. +/// +/// Uses `set_builtin_accessor_descriptor` (gate-neutral): it does not flip +/// `GLOBAL_DESCRIPTORS_IN_USE` / `ACCESSORS_IN_USE`, so ordinary property +/// read/write fast paths are unaffected for every OTHER key. `__proto__` +/// itself was already treated as unconditionally interceptable by +/// `object_proto_may_intercept_key` / `plain_custom_prototype_may_intercept` +/// (see `object/descriptor_state.rs`) before this change, so installing a +/// real descriptor for it changes no hot-path gate this key didn't already +/// trip — only what reflection sees. +/// +/// The getter delegates to `js_object_get_prototype_of`, which already +/// implements the getter's exact spec shape (ToObject-style wrapper +/// resolution for primitives, Proxy/Temporal/handle receivers, and a throw +/// on `null`/`undefined`). The setter delegates to +/// `proxy::legacy_dunder_proto_set`, the same Annex-B logic `proxy.rs`'s +/// `ordinary_set_with_receiver` used to inline for this one key (#6828) — +/// now shared so both call sites can never drift apart. Once this +/// descriptor exists, `own_set_descriptor` finds it and dispatches through +/// the ordinary accessor-setter path before that inlined special case is +/// ever reached (see the comment there). +fn install_object_prototype_dunder_proto(proto_obj: *mut ObjectHeader) { + if proto_obj.is_null() { + return; + } + let getter = crate::closure::js_closure_alloc( + object_prototype_dunder_proto_getter_thunk as *const u8, + 0, + ); + let setter = crate::closure::js_closure_alloc( + object_prototype_dunder_proto_setter_thunk as *const u8, + 0, + ); + if getter.is_null() || setter.is_null() { + return; + } + crate::closure::js_register_closure_arity( + object_prototype_dunder_proto_getter_thunk as *const u8, + 0, + ); + crate::closure::js_register_closure_arity( + object_prototype_dunder_proto_setter_thunk as *const u8, + 1, + ); + super::super::native_module::set_bound_native_closure_name(getter, "get __proto__"); + super::super::native_module::set_bound_native_closure_name(setter, "set __proto__"); + super::super::native_module::set_builtin_closure_length(getter as usize, 0); + super::super::native_module::set_builtin_closure_length(setter as usize, 1); + super::super::native_module::set_builtin_closure_non_constructable(getter as usize); + super::super::native_module::set_builtin_closure_non_constructable(setter as usize); + let get_bits = crate::value::js_nanbox_pointer(getter as i64).to_bits(); + let set_bits = crate::value::js_nanbox_pointer(setter as i64).to_bits(); + // A descriptor alone doesn't make the name enumerable by + // `getOwnPropertyNames`/`hasOwnProperty`/`Object.hasOwn`/ + // `Reflect.ownKeys` — those walk the object's OWN KEYS ARRAY, which + // `set_builtin_accessor_descriptor` (deliberately gate-neutral) never + // touches. Write an ordinary placeholder field first, exactly like + // `perf_hooks::install_perf_getter`: this appends `"__proto__"` to the + // keys array via the ordinary field-set path, and the accessor + // descriptor installed right after takes over every actual read/write — + // the placeholder `undefined` is never observed. + let key = crate::string::js_string_from_bytes(b"__proto__".as_ptr(), 9); + js_object_set_field_by_name(proto_obj, key, f64::from_bits(crate::value::TAG_UNDEFINED)); + super::super::set_builtin_accessor_descriptor( + proto_obj as usize, + "__proto__".to_string(), + super::super::AccessorDescriptor { + get: get_bits, + set: set_bits, + }, + crate::object::PropertyAttrs::new(true, false, true), + ); +} + +extern "C" fn object_prototype_dunder_proto_getter_thunk( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + // Spec (Annex B §B.3.1 `get __proto__`): `ToObject(this).[[GetPrototypeOf]]()`. + // `js_object_get_prototype_of` already implements exactly this shape — + // wrapper-prototype resolution for primitives, Proxy/Temporal/handle + // receivers, and a throw on `null`/`undefined` (the `ToObject` failure + // case) — so the getter is a direct delegation, not a reimplementation. + let receiver = crate::object::js_implicit_this_get(); + crate::object::js_object_get_prototype_of(receiver) +} + +extern "C" fn object_prototype_dunder_proto_setter_thunk( + _closure: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + let receiver = crate::object::js_implicit_this_get(); + crate::proxy::legacy_dunder_proto_set(receiver, value); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: *mut ObjectHeader) { if proto_obj.is_null() { return; @@ -399,6 +502,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: object_prototype_property_is_enumerable_thunk as *const u8, 1, ); + install_object_prototype_dunder_proto(proto_obj); } "Function" => { // `Function.prototype` has own `length` (0) and `name` ("") data diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index f95d8286d6..b9094b1c92 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -767,6 +767,30 @@ fn reflect_value_is_symbol(value: f64) -> bool { && unsafe { crate::symbol::js_is_symbol(value) != 0 } } +/// #6828/#10482: Annex B §B.3.1 `set __proto__` semantics — shared by the +/// real accessor descriptor installed on `Object.prototype` +/// (`object/global_this/proto_methods.rs`'s setter closure, reached via +/// `own_set_descriptor` + `call_setter_with_receiver` above) and this +/// function's own caller (the walk's defensive fallback for the same key). +/// Both must behave identically, so both call this one implementation +/// instead of keeping the logic written out twice. +/// +/// Per spec: a non-object/non-null `value` or a non-object `receiver` is +/// silently ignored (no throw, unlike `Object.setPrototypeOf`); a genuine +/// `[[SetPrototypeOf]]` failure (cyclic / non-extensible) still throws via +/// `js_object_set_prototype_of` itself, matching `Object.setPrototypeOf`'s +/// failure behavior for that case. +pub(crate) fn legacy_dunder_proto_set(receiver: f64, value: f64) { + let value_bits = value.to_bits(); + let valid_proto = value_bits == TAG_NULL + || lookup(value).is_some() + || crate::object::class_ref_id(value).is_some() + || unsafe { crate::object::value_is_object_like(value) }; + if valid_proto && reflect_value_is_object(receiver) { + crate::object::js_object_set_prototype_of(receiver, value); + } +} + /// Is `value` a Reflect-acceptable object? Heap objects, class refs (callable /// constructors), and proxies all count. Primitives / null / undefined do not. pub(crate) fn reflect_value_is_object(value: f64) -> bool { @@ -2207,31 +2231,28 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) } }; } - // #6828: `%Object.prototype%.__proto__` is a legacy accessor whose - // setter performs `SetPrototypeOf(Receiver, value)`. Perry exposes the - // getter intrinsically but does not materialize the built-in accessor - // in the ordinary descriptor table, so model it at the exact point in - // the [[Set]] walk where that descriptor would be found. + // #6828/#10482: `%Object.prototype%.__proto__` is a legacy accessor + // whose setter performs `SetPrototypeOf(Receiver, value)`. + // `object/global_this/proto_methods.rs` now materializes it as a + // REAL accessor descriptor on `Object.prototype` (#10482), so + // `own_set_descriptor` just above finds it and dispatches through + // `call_setter_with_receiver` before the walk ever reaches here — + // this arm is kept as a fallback for a walk that reaches + // `Object.prototype` without ever consulting the descriptor table + // (defensive; not known to be reachable). Both arms must behave + // identically, so both call the one shared implementation. // // Keep this AFTER `own_set_descriptor`: a user-installed own // `__proto__` data/accessor property on an object earlier in the chain // must win. A null-prototype receiver never reaches the canonical // Object.prototype and therefore still creates an ordinary own data - // property. Per Annex B, a primitive RHS is ignored rather than - // throwing (unlike `Object.setPrototypeOf`). + // property. let current_addr = extract_pointer(current.to_bits()) as usize; if current_addr != 0 && current_addr == crate::array::object_prototype_addr() && key_to_rust_string(key).as_deref() == Some("__proto__") { - let value_bits = value.to_bits(); - let valid_proto = value_bits == TAG_NULL - || lookup(value).is_some() - || crate::object::class_ref_id(value).is_some() - || unsafe { crate::object::value_is_object_like(value) }; - if valid_proto && reflect_value_is_object(receiver) { - crate::object::js_object_set_prototype_of(receiver, value); - } + legacy_dunder_proto_set(receiver, value); return true; } if crate::closure::is_closure_ptr(extract_pointer(current.to_bits()) as usize) { From 909aa2f34b4243a66ba0b3799e1a48080b3362cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 17:19:08 +0000 Subject: [PATCH 06/23] test(gap): cover Object.prototype.__proto__ reflection and behavioral parity --- ...gap_10482_object_prototype_dunder_proto.ts | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 test-files/test_gap_10482_object_prototype_dunder_proto.ts diff --git a/test-files/test_gap_10482_object_prototype_dunder_proto.ts b/test-files/test_gap_10482_object_prototype_dunder_proto.ts new file mode 100644 index 0000000000..5f945c9a3b --- /dev/null +++ b/test-files/test_gap_10482_object_prototype_dunder_proto.ts @@ -0,0 +1,186 @@ +// #10482: Object.prototype has no own __proto__ accessor, so +// hasOwnProperty/Object.hasOwn/getOwnPropertyNames/getOwnPropertyDescriptor +// disagree with Node about it, and a `hasOwnProperty.call(Object.prototype, +// key)` prototype-pollution guard (the qs idiom) lets "__proto__" through. +// +// Node has `Object.prototype.__proto__` as a real accessor property: +// { get: [Function], set: [Function], enumerable: false, configurable: true }. + +const has = Object.prototype.hasOwnProperty; + +// --- Descriptor shape ------------------------------------------------- +const d = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__"); +console.log( + "descriptor shape:", + typeof d?.get, + typeof d?.set, + d?.enumerable, + d?.configurable, + d ? "value" in d : false, +); + +// --- Reflection entry points must agree -------------------------------- +console.log("hasOwnProperty.call:", has.call(Object.prototype, "__proto__")); +console.log("Object.hasOwn:", Object.hasOwn(Object.prototype, "__proto__")); +console.log( + "getOwnPropertyNames includes:", + Object.getOwnPropertyNames(Object.prototype).includes("__proto__"), +); +console.log( + "Reflect.ownKeys includes:", + Reflect.ownKeys(Object.prototype).includes("__proto__"), +); +console.log('"__proto__" in {}:', "__proto__" in {}); + +// --- Enumerability: accessor is non-enumerable ------------------------- +console.log( + "Object.keys(Object.prototype) excludes it:", + !Object.keys(Object.prototype).includes("__proto__"), +); +console.log( + "Object.entries(Object.prototype) excludes it:", + !Object.entries(Object.prototype).some(([k]) => k === "__proto__"), +); +console.log( + "propertyIsEnumerable:", + Object.prototype.propertyIsEnumerable.call(Object.prototype, "__proto__"), +); +{ + let sawIt = false; + for (const k in {}) { + if (k === "__proto__") sawIt = true; + } + console.log("for-in over {} excludes it:", !sawIt); +} + +// --- The qs-style prototype-pollution guard idiom ----------------------- +const keys = ["__proto__", "toString", "b"]; +console.log( + "guarded keys (qs idiom):", + keys.filter((k) => !has.call(Object.prototype, k)).join(","), +); + +// --- Behavioural read/write must still work ----------------------------- + +// Plain object. +{ + const target: any = { inherited: "yes" }; + const plain: any = {}; + plain.__proto__ = target; + console.log( + "plain object: read===write target, getPrototypeOf agrees:", + plain.__proto__ === target, + Object.getPrototypeOf(plain) === target, + ); +} + +// Object.create(null): no legacy setter on the chain, so assignment +// creates an ordinary OWN enumerable data property instead of reparenting. +{ + const target: any = { inherited: "yes" }; + const nullProto: any = Object.create(null); + nullProto.__proto__ = target; + console.log( + "null-proto object: stays null-proto, own data prop, key present:", + Object.getPrototypeOf(nullProto) === null, + Object.prototype.hasOwnProperty.call(nullProto, "__proto__"), + Object.keys(nullProto).join(","), + ); +} + +// An own descriptor earlier in the chain shadows the inherited accessor. +{ + const ownProtoData: any = {}; + Object.defineProperty(ownProtoData, "__proto__", { + value: "before", + writable: true, + enumerable: true, + configurable: true, + }); + const parentBefore = Object.getPrototypeOf(ownProtoData); + ownProtoData.__proto__ = "after"; + console.log( + "own __proto__ data prop shadows the accessor:", + ownProtoData.__proto__, + Object.getPrototypeOf(ownProtoData) === parentBefore, + ); +} + +// A non-object, non-null RHS is silently ignored (Annex B), not thrown. +{ + const target: any = { inherited: "yes" }; + const assigned: any = {}; + assigned.__proto__ = target; + assigned.__proto__ = 7; + console.log( + "primitive RHS ignored, no throw:", + Object.getPrototypeOf(assigned) === target, + ); +} + +// Declared class instance (CLASS_DECL_PROTOTYPE_OBJECTS). +{ + class Base {} + class Derived extends Base {} + const inst = new Derived(); + console.log( + "declared class instance:", + (inst as any).__proto__ === Derived.prototype, + Object.getPrototypeOf(Derived.prototype) === Base.prototype, + ); +} + +// Plain-function constructor instance (CLASS_PROTOTYPE_OBJECTS). +{ + function Ctor(this: any) { + this.x = 1; + } + const inst: any = new (Ctor as any)(); + console.log( + "function-ctor instance:", + inst.__proto__ === (Ctor as any).prototype, + ); +} + +// Object.create(proto) synthetic object (also CLASS_PROTOTYPE_OBJECTS-style +// resolution). +{ + const base = { greet: "hi" }; + const created: any = Object.create(base); + console.log( + "Object.create(proto) synthetic object:", + created.__proto__ === base, + ); +} + +// Primitives — auto-boxed to their wrapper's prototype on read. +console.log("number primitive:", (5 as any).__proto__ === Number.prototype); +console.log( + "string primitive:", + ("s" as any).__proto__ === String.prototype, +); + +// --- Object-literal `__proto__` stays the special non-computed form ------ +{ + const litProto = { fromLiteral: true }; + const lit: any = { __proto__: litProto, y: 2 }; + console.log( + "literal __proto__ sets prototype, not an own key:", + Object.getPrototypeOf(lit) === litProto, + !Object.prototype.hasOwnProperty.call(lit, "__proto__"), + lit.y, + ); +} + +// A COMPUTED key that evaluates to "__proto__" is an ordinary own property — +// the special form only applies to the non-computed `__proto__: value` shape. +{ + const key = "__proto__"; + const computed: any = { [key]: 99 }; + console.log( + "computed __proto__ key is an ordinary own data property:", + Object.getPrototypeOf(computed) === Object.prototype, + Object.prototype.hasOwnProperty.call(computed, "__proto__"), + computed.__proto__, + ); +} From ce2c3afec8eded3d2aca09cd9ed1fa4580209955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 17:20:38 +0000 Subject: [PATCH 07/23] docs(changelog): fragment for #10647 Object.prototype.__proto__ accessor --- changelog.d/10647-object-prototype-dunder-proto.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/10647-object-prototype-dunder-proto.md diff --git a/changelog.d/10647-object-prototype-dunder-proto.md b/changelog.d/10647-object-prototype-dunder-proto.md new file mode 100644 index 0000000000..c1ecf83dc4 --- /dev/null +++ b/changelog.d/10647-object-prototype-dunder-proto.md @@ -0,0 +1,4 @@ +### Fixed + +- `Object.prototype` now has a real, spec-shaped `__proto__` accessor (`{ get, set, enumerable: false, configurable: true }`), so `hasOwnProperty`, `Object.hasOwn`, `Object.getOwnPropertyNames`, `Object.getOwnPropertyDescriptor`, `Reflect.ownKeys`, and `"__proto__" in obj` all agree with Node about it — closing a prototype-pollution guard bypass in libraries (e.g. `qs`) that use `hasOwnProperty.call(Object.prototype, key)` to reject `"__proto__"` as a key. +- Fixed a related bug the new accessor exposed: reading `.__proto__` on a `Number`/`String` primitive via a dynamic property access (`(5).__proto__`) could invoke an accessor inherited from `Object.prototype` with `this` bound to the intermediate builtin prototype (`Number.prototype`) instead of the original primitive, answering `Object.prototype` instead of `Number.prototype`. From e379880003cf1007d1d617f307a072474e2837af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:37:32 +0000 Subject: [PATCH 08/23] fix(hir): replace this inside GetIterator/GetAsyncIterator/MapEntries/SetValues when lifting a Symbol.iterator generator method A generator method keyed by [Symbol.iterator] is lifted to a top-level function with this as an explicit param (synthesize_symbol_iterator_wrapper in lower_decl/class_decl.rs), and replace_this_in_stmts rewrites Expr::This to that param throughout the body. Its expression walker was missing arms for GetIterator/GetAsyncIterator/MapEntries/SetValues -- the wrapper exprs a for-of iterable lowers to when it cannot be proven a plain Array/Map/Set (stmt_loops.rs lower_stmt_for_of_inner). A for-of over this.gen() inside such a method left an unreplaced Expr::This nested inside one of these wrappers, which evaluates to undefined outside any method body: Cannot read properties of undefined (reading 'gen'). --- crates/perry-hir/src/analysis.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index b9fba65cee..0b62440897 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -1154,6 +1154,25 @@ fn replace_this_in_expr(expr: &mut Expr, this_id: LocalId) { replace_this_in_expr(else_expr, this_id); } Expr::Await(inner) => replace_this_in_expr(inner, this_id), + // #10445: a `for…of`/`for await…of` iterable that can't be proven a + // plain Array/Map/Set lowers to one of these wrapper exprs around the + // ORIGINAL receiver expression (see `stmt_loops.rs`'s + // `lower_stmt_for_of_inner` — `Expr::GetIterator`/`GetAsyncIterator` + // wrap the lazy-iterator-protocol receiver, `MapEntries`/`SetValues` + // wrap a Map/Set whose fast path is disabled). Missing them here left + // a `for (const x of this.gen())` inside a lifted + // `*[Symbol.iterator]()` generator (`synthesize_symbol_iterator_wrapper` + // below, which lifts the method to a top-level function and replaces + // `this` with an explicit param) with an unreplaced `Expr::This` deep + // inside the wrapper — it fell through to the catch-all and evaluated + // to `undefined` outside any method body, `Cannot read properties of + // undefined (reading 'gen')`. Hoisting the same call into a local + // first (`const it = this.gen(); for (const x of it)`) sidestepped + // the bug because the plain `Stmt::Let` init IS a matched `Expr::Call`. + Expr::GetIterator(inner) | Expr::GetAsyncIterator(inner) => { + replace_this_in_expr(inner, this_id) + } + Expr::MapEntries(inner) | Expr::SetValues(inner) => replace_this_in_expr(inner, this_id), Expr::Yield { value, .. } => { if let Some(v) = value { replace_this_in_expr(v, this_id); From f491b89f0e77d08b69e03c958eb6c33ae2b18d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 17:25:58 +0000 Subject: [PATCH 09/23] test(gap): cover Symbol.iterator generator this-binding through for-of/spread/Array.from Covers the #10445 repro (spread, for-of, Array.from, named-generator control), a two-level generator chain (iterator method's for-of iterable is itself another method that also iterates via this), a Symbol.iterator generator on a class EXPRESSION, and yield* delegation alongside a for-of over this.method() in the same generator. --- ...ap_10445_symbol_iterator_generator_this.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 test-files/test_gap_10445_symbol_iterator_generator_this.ts diff --git a/test-files/test_gap_10445_symbol_iterator_generator_this.ts b/test-files/test_gap_10445_symbol_iterator_generator_this.ts new file mode 100644 index 0000000000..a90843efb7 --- /dev/null +++ b/test-files/test_gap_10445_symbol_iterator_generator_this.ts @@ -0,0 +1,89 @@ +// #10445: a generator method keyed by `[Symbol.iterator]`, whose `for…of` +// iterable is a method call on `this` (`for (const x of this.gen()) …`), +// saw `this === undefined` inside the callee. Root cause: +// `synthesize_symbol_iterator_wrapper` (lower_decl/class_decl.rs) lifts the +// method's body to a top-level generator taking `this` as an explicit +// param, then `replace_this_in_stmts`/`replace_this_in_expr` (analysis.rs) +// rewrites every `Expr::This` in that body to the param. A `for…of` whose +// iterable can't be proven a plain Array/Map/Set lowers to one of +// `GetIterator`/`GetAsyncIterator`/`MapEntries`/`SetValues` wrapping the +// receiver expression (stmt_loops.rs's `lower_stmt_for_of_inner`) -- and +// `replace_this_in_expr` had no arm for any of those wrappers, so a `this` +// buried inside one fell through to the catch-all and was left unreplaced. +// Every consumer that dispatches through the lifted function (spread, +// `for…of`, `Array.from`) hit the same bug identically. + +class Bag { + items = [1, 2]; + *gen() { + yield* this.items; + } + *[Symbol.iterator]() { + for (const x of this.gen()) yield x; // the repro shape + } + *viaLocal() { + for (const x of this.gen()) yield x; // same body, ordinary name: control + } +} + +// Two-level: the iterator method's for-of iterable is ANOTHER method whose +// OWN for-of iterable is a THIRD method -- this must survive two hops of +// the lifted-generator's this-substitution, not just one. +class TwoLevel { + items = [10, 20, 30]; + *inner() { + for (const x of this.items) yield x * 2; + } + *middle() { + for (const x of this.inner()) yield x + 1; + } + *[Symbol.iterator]() { + for (const x of this.middle()) yield x; + } +} + +// Class EXPRESSION (not a declaration) -- the lift/this-rewrite must not be +// keyed off a named-declaration-only path. +const ExprClass = class { + items = ["a", "b", "c"]; + *gen() { + yield* this.items; + } + *[Symbol.iterator]() { + for (const x of this.gen()) yield x; + } +}; + +// `yield*` delegation alongside a for-of over `this.method()` in the SAME +// generator -- confirms the fix doesn't disturb the already-working +// yield*-over-this.gen() path while also fixing the for-of one. +class Mixed { + items = [1, 2, 3]; + *gen() { + yield* this.items; + } + *[Symbol.iterator]() { + yield* this.gen(); + for (const x of this.gen()) yield x * 10; + } +} + +const show = (label: string, f: () => unknown) => { + try { + console.log(label, JSON.stringify(f())); + } catch (e: any) { + console.log(label, "threw:", e.message); + } +}; + +show("spread over *[Symbol.iterator]:", () => [...new Bag()]); +show("for-of over *[Symbol.iterator]:", () => { + const out: number[] = []; + for (const x of new Bag()) out.push(x); + return out; +}); +show("Array.from(bag):", () => Array.from(new Bag())); +show("named generator, same body:", () => [...new Bag().viaLocal()]); +show("two-level generator:", () => [...new TwoLevel()]); +show("class expression generator:", () => [...new ExprClass()]); +show("yield* + for-of mixed:", () => [...new Mixed()]); From c64c91fc52cc0dff40fa2ea71ad3908779133f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 17:27:19 +0000 Subject: [PATCH 10/23] changelog: fragment for #10650 --- .../10650-symbol-iterator-generator-this.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 changelog.d/10650-symbol-iterator-generator-this.md diff --git a/changelog.d/10650-symbol-iterator-generator-this.md b/changelog.d/10650-symbol-iterator-generator-this.md new file mode 100644 index 0000000000..360d715b5e --- /dev/null +++ b/changelog.d/10650-symbol-iterator-generator-this.md @@ -0,0 +1,26 @@ +Fixed: a class generator method keyed by `[Symbol.iterator]`, when a `for…of` +loop inside it iterated a method call on `this` (`for (const x of +this.gen()) yield x;`), saw `this === undefined` and threw `Cannot read +properties of undefined (reading 'gen')`. Every consumer that dispatches +through the class's iterator protocol (`for…of`, spread, `Array.from`) hit it +identically; the identical body under an ordinary method name worked. + +Root cause: lifting a `*[Symbol.iterator]()` method to its top-level +generator (`synthesize_symbol_iterator_wrapper`) rewrites `this` to an +explicit parameter via `replace_this_in_stmts`/`replace_this_in_expr` +(`crates/perry-hir/src/analysis.rs`). That rewrite had no arm for +`Expr::GetIterator`/`GetAsyncIterator`/`MapEntries`/`SetValues` — the wrapper +expressions a `for…of` iterable lowers to when it can't be proven a plain +Array/Map/Set — so a `this` buried inside one of those wrappers fell through +to the catch-all and was never rewritten. + +Fix: added the four missing arms, recursing into the wrapped expression the +same way the existing `Await`/`TypeOf`/`Void` arms do. + +Validation: new gap test (`test_gap_10445_symbol_iterator_generator_this.ts`) +covering the issue repro plus a two-level generator chain, a +`Symbol.iterator` generator on a class expression, and `yield*` delegation +alongside a `for…of` over `this.method()` — proven to fail on the pre-fix +tree and pass on this one, byte-identical to Node 26.5.1. `cargo test +--release -p perry-hir --tests`: 748 passed. Lint: 76/77 gates (the one red +is the pre-existing, repo-wide benchmark-freshness check). From 70f36c1be5a8b665b322c5b2c596afcc52a089f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:59:26 +0200 Subject: [PATCH 11/23] perf(runtime): stop hoisting cold-path thread-locals into o[k]'s fast lane js_object_get_field_by_name's Proxy-receiver block and RuntimeHandleScope's raw-thread_local! fallback were both small enough to inline into the hot dynamic-key-read path. A thread_local! address resolution is readnone from LLVM's point of view, so once inlined, the optimizer hoisted the proxy registry's and the transient-handle root stack's TLS lookups out of their guards (is_proxy_id_band, a "size"-key check) and ran them unconditionally on every js_object_get_field_by_name call, Proxy or not. Splitting each into its own #[inline(never)] function keeps the optimizer from seeing inside at the call site, so nothing gets hoisted past the guard. Also skip try_read_gc_header's redundant is_plausible_heap_addr recheck in try_data_get_bytes's prototype-chain loop, where the caller already proved it true one statement above. Measured on a two-property-object o[k] loop (never touching a Proxy or a .size key): 544.9 -> 513.0 instructions/access (-5.9%), via (dyn80-dyn16)/64 differenced against 2x the iteration count to cancel per-process fixed overhead. The (loop80-loop16)/64 no-op control reads ~0 in both arms (base: -0.01..-0.04, mine: -0.02..0.13), confirming the technique resolves changes this small. Verified via disassembly that both _tlv_get_addr calls are gone from the function's prologue. --- .../src/gc/roots/runtime_handles.rs | 28 +++++++++ .../object/field_get_set/get_field_by_name.rs | 35 +++++++++-- crates/perry-runtime/src/object/native_get.rs | 5 +- crates/perry-runtime/src/value/addr_class.rs | 23 +++++++ .../test_gap_dynamic_key_proxy_receiver.ts | 60 +++++++++++++++++++ 5 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 test-files/test_gap_dynamic_key_proxy_receiver.ts diff --git a/crates/perry-runtime/src/gc/roots/runtime_handles.rs b/crates/perry-runtime/src/gc/roots/runtime_handles.rs index 755d767261..15859d964d 100644 --- a/crates/perry-runtime/src/gc/roots/runtime_handles.rs +++ b/crates/perry-runtime/src/gc/roots/runtime_handles.rs @@ -57,6 +57,34 @@ fn runtime_handle_stack() -> StackRef { return unsafe { &*(stack as *const RuntimeHandleStack) }; } } + runtime_handle_stack_cold() +} + +/// Fallback arm of [`runtime_handle_stack`]: the raw `thread_local!` lookup, +/// reached only before this thread's `HotTls` is published (or from inside +/// `HotTls::fill` itself). `#[inline(never)]` on purpose, not just `#[cold]`. +/// +/// `RuntimeHandleScope::new()` is called from dozens of arms throughout the +/// runtime, many of them small and gated behind a cheap guard deep inside an +/// otherwise hot function (`js_object_get_field_by_name`'s `.size`-key arm is +/// one: see its own `RuntimeHandleScope::new()` call site, guarded on the key +/// bytes equalling `"size"`, with a comment already defending against making +/// the SCOPE unconditional). `crate::tls_hot`'s #7469 note explains why that +/// defense is not enough on its own: a `thread_local!` address resolution is +/// `readnone` from the optimizer's point of view — it has no observable side +/// effect — so once the fallback arm above is visible to the inliner at such a +/// call site, LLVM can (and does) hoist JUST that address computation out of +/// every surrounding guard and run it unconditionally, regardless of how +/// deeply the Rust-level scope construction is gated. Measured: on an `o[k]` +/// loop over a two-property plain object (never touching a `.size` key or a +/// Proxy), this fallback's `_tlv_get_addr` call sat directly in +/// `js_object_get_field_by_name`'s prologue. Keeping this arm opaque to the +/// inliner is what lets the FAST (published) arm above stay `#[inline(always)]` +/// without dragging the raw TLS call along with it at every call site. +#[inline(never)] +#[cold] +#[cfg(not(any(target_os = "android", target_env = "ohos")))] +fn runtime_handle_stack_cold() -> StackRef { RUNTIME_HANDLE_STACK.with(|stack| { // SAFETY: the metadata is const-initialized and has no Drop. Its // cells remain valid throughout thread teardown. Cell is !Sync, so 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 5032f7d384..27241ffd51 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 @@ -22,6 +22,33 @@ fn handle_proto_inherited_field( } } +/// #2846 Proxy-receiver forwarding for a generic property read. Split out and +/// `#[inline(never)]` on purpose: `js_proxy_is_proxy` (via `lookup`) and +/// `js_proxy_get` (via `RuntimeHandleScope::new`) each resolve their own +/// `thread_local!` (the proxy registry, the transient-handle root stack). +/// Both accessors are pure address computations from LLVM's point of view — +/// `readnone`, no observable side effect — so once this code was inlined into +/// `js_object_get_field_by_name` the optimizer hoisted BOTH out of the +/// `is_proxy_id_band` guard above them and ran them unconditionally on every +/// call, proxy receiver or not. Measured on an `o[k]` loop over a two-property +/// plain object (never a Proxy): two `_tlv_get_addr` calls sitting directly in +/// `js_object_get_field_by_name`'s prologue, 9.2% of the whole access. +/// `#[inline(never)]` keeps the optimizer from seeing inside this function at +/// the call site, so it cannot hoist anything out of it; `is_proxy_id_band` +/// itself stays inline in the caller since it touches no thread-local. +#[cold] +#[inline(never)] +fn proxy_receiver_get(raw_addr: u64, key: *const crate::StringHeader) -> Option { + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; + let boxed = f64::from_bits(POINTER_TAG | (raw_addr & 0x0000_FFFF_FFFF_FFFF)); + if crate::proxy::js_proxy_is_proxy(boxed) == 0 { + return None; + } + let key_f64 = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); + let v = crate::proxy::js_proxy_get(boxed, key_f64); + Some(JSValue::from_bits(v.to_bits())) +} + #[no_mangle] pub extern "C" fn js_object_get_field_by_name( obj: *const ObjectHeader, @@ -105,12 +132,8 @@ pub extern "C" fn js_object_get_field_by_name( addr }; if crate::value::addr_class::is_proxy_id_band(raw_addr as usize) && !key.is_null() { - const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; - let boxed = f64::from_bits(POINTER_TAG | (raw_addr & 0x0000_FFFF_FFFF_FFFF)); - if crate::proxy::js_proxy_is_proxy(boxed) != 0 { - let key_f64 = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); - let v = crate::proxy::js_proxy_get(boxed, key_f64); - return JSValue::from_bits(v.to_bits()); + if let Some(value) = proxy_receiver_get(raw_addr, key) { + return value; } } } diff --git a/crates/perry-runtime/src/object/native_get.rs b/crates/perry-runtime/src/object/native_get.rs index 072b8eb435..b3315dd4d3 100644 --- a/crates/perry-runtime/src/object/native_get.rs +++ b/crates/perry-runtime/src/object/native_get.rs @@ -70,7 +70,10 @@ pub(crate) unsafe fn try_data_get_bytes(receiver: JSValue, key: &[u8]) -> Option { return None; } - let header = crate::value::addr_class::try_read_gc_header(addr)?; + // `is_plausible_heap_addr(addr)` was just proven true above; skip + // `try_read_gc_header`'s own re-derivation of it (see + // `try_read_gc_header_known_plausible`'s doc comment). + let header = crate::value::addr_class::try_read_gc_header_known_plausible(addr)?; if header.obj_type != crate::gc::GC_TYPE_OBJECT || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 || header._reserved & crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO != 0 diff --git a/crates/perry-runtime/src/value/addr_class.rs b/crates/perry-runtime/src/value/addr_class.rs index 4d37c9a7d9..466f0352ee 100644 --- a/crates/perry-runtime/src/value/addr_class.rs +++ b/crates/perry-runtime/src/value/addr_class.rs @@ -238,6 +238,29 @@ pub(crate) unsafe fn try_read_gc_header(addr: usize) -> Option<&'static GcHeader if !is_plausible_heap_addr(addr) { return None; } + try_read_gc_header_known_plausible(addr) +} + +/// [`try_read_gc_header`] for a caller that already ran +/// [`is_plausible_heap_addr`] on this exact `addr` earlier in the same +/// straight-line scope, with no intervening collection or reassignment of +/// `addr`. Skips re-deriving that magnitude check. +/// +/// `native_get::try_data_get_bytes`'s prototype-chain loop already branches +/// on `is_plausible_heap_addr(addr)` (paired with the arena-generation +/// classification) one statement above every call site this exists for, so +/// the plain [`try_read_gc_header`] was re-running the same handle-band / +/// heap-range compare a second time per step for free. `classify_heap_generation` +/// runs in between and writes its own cache, which is enough to stop LLVM's +/// CSE from eliding the duplicate call on its own (its side effect isn't +/// provably unrelated to `is_plausible_heap_addr`'s inputs from the +/// optimizer's point of view), so the redundancy was real, not just apparent. +/// +/// # Safety +/// As [`try_read_gc_header`], plus: `is_plausible_heap_addr(addr)` must be +/// `true` for this `addr` already (unchecked here). +#[inline(always)] +pub(crate) unsafe fn try_read_gc_header_known_plausible(addr: usize) -> Option<&'static GcHeader> { // Small-buffer slab allocations are heap-plausible but carry NO GcHeader — // `addr - GC_HEADER_SIZE` is the previous slab entry's data bytes, so a // brand probe (Temporal/Date/Map/Set `obj_type` check) would read a diff --git a/test-files/test_gap_dynamic_key_proxy_receiver.ts b/test-files/test_gap_dynamic_key_proxy_receiver.ts new file mode 100644 index 0000000000..6866b8b276 --- /dev/null +++ b/test-files/test_gap_dynamic_key_proxy_receiver.ts @@ -0,0 +1,60 @@ +// Dynamic-key reads, `o[k]`, through a Proxy receiver. +// +// `js_object_get_field_by_name`'s Proxy-forwarding block never satisfies the +// ordinary-object fast data-get lane above it (a Proxy's boxed encoding is a +// small registry id, not a real heap pointer), so it was pulled out into its +// own `#[inline(never)]` helper (`proxy_receiver_get`): inlined in place, the +// compiler proved the proxy registry's and the transient-handle root stack's +// `thread_local!` address resolutions were side-effect-free and hoisted BOTH +// out of the `is_proxy_id_band` guard, so an `o[k]` loop over a plain +// two-property object — never touching a Proxy — paid two `_tlv_get_addr` +// calls on every access. This exercises that the extraction is behavior +// preserving: trapped reads, pass-through reads (no `get` trap), a +// forward-to-target hop through a nested Proxy, and a Proxy loop running +// right alongside an ordinary-object loop on the same key. +const target: any = { a: 1, b: 2 }; + +const trapped = new Proxy(target, { + get(t, prop, receiver) { + if (prop === "special") return "trapped!"; + return Reflect.get(t, prop, receiver); + }, +}); +for (const k of ["a", "b", "special", "missing"]) { + console.log("trapped", k, String(trapped[k])); +} + +// No `get` trap: falls through to the target's own [[Get]]. +const passthrough = new Proxy(target, {}); +for (const k of ["a", "b", "missing"]) { + console.log("passthrough", k, String(passthrough[k])); +} + +// Nested Proxy: a dynamic-key read that recurses through the +// forward-to-target hop inside the outlined helper. +const inner = new Proxy(target, { + get(t, p) { + return (t as any)[p]; + }, +}); +const outer = new Proxy(inner, {}); +console.log("nested", outer["a"]); + +// An ordinary-object loop and a Proxy loop on the same key, back to back — +// the ordinary loop must not pay for the Proxy path, and the Proxy loop must +// still resolve correctly through it. +const plain: any = { k: 1.5, other: 2 }; +let plainTotal = 0; +for (let i = 0; i < 20; i++) plainTotal += plain["k"]; +console.log("plain total", plainTotal); + +let proxyTotal = 0; +for (let i = 0; i < 20; i++) proxyTotal += Number(trapped["a"]); +console.log("proxy total", proxyTotal); + +// A Proxy over an array, read by dynamic numeric-string key. +const arrTarget = [10, 20, 30]; +const arrProxy = new Proxy(arrTarget, {}); +for (const k of ["0", "1", "2", "length"]) { + console.log("arr proxy", k, String((arrProxy as any)[k])); +} From a423ccc5613e9f4b1a81d14644e3e64ec2233d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 19:30:11 +0200 Subject: [PATCH 12/23] changelog: fragment for #10651 --- changelog.d/10651-dynamic-key-cold-tls.md | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 changelog.d/10651-dynamic-key-cold-tls.md diff --git a/changelog.d/10651-dynamic-key-cold-tls.md b/changelog.d/10651-dynamic-key-cold-tls.md new file mode 100644 index 0000000000..ca60800c27 --- /dev/null +++ b/changelog.d/10651-dynamic-key-cold-tls.md @@ -0,0 +1,41 @@ +**perf(runtime): stop hoisting cold-path thread-locals into `o[k]`'s fast lane.** +`js_object_get_field_by_name` resolved two thread-locals unconditionally in its +prologue — the `PROXIES` registry and `RUNTIME_HANDLE_STACK`'s fallback — on a +dynamic-key read loop that never touches a Proxy and never uses a `.size` key. +Both belong to arms that are guarded at the Rust level and never taken. + +A `thread_local!` address resolution is `readnone` to LLVM, so once a guarded +cold arm is inlined the optimizer may hoist *just the address computation* above +its guard: the gate survives, the TLS call escapes it. Marking both arms +`#[cold] #[inline(never)]` keeps them opaque to the inliner. The +`runtime_handles` half is the general fix — `RuntimeHandleScope::new()` is called +from dozens of small guarded arms across the runtime, and splitting its fallback +lets the published fast arm stay `#[inline(always)]` without dragging a raw TLS +call to every call site. + +Also: `try_data_get_bytes` called `is_plausible_heap_addr` explicitly and again +inside `try_read_gc_header`, which LLVM could not CSE across +`classify_heap_generation`'s intervening cache write; that one site now uses +`try_read_gc_header_known_plausible`. + +**544.7 → 512.9 instructions per `o[k]` access (−5.8%)**, differenced within each +binary against a bare-loop control reading 0.00 / −0.10 so layout and fixed +per-process cost cancel. Found by disassembly, not by reading: the profile +charged both `_tlv_get_addr` calls to `js_object_get_field_by_name` itself rather +than to a callee, and `otool -tV` plus `nm` named which two thread-locals they +were. Fixing the Proxy block alone left the other in place by a different route. + +Negative results worth not re-running: `try_data_get_bytes`'s `from_utf8` and +Bloom-hash preamble is spec-required work; `is_anon_shape_class_id`'s remaining +11.2% is the per-image `current()` lookup that #10570 already reduced to a hash +plus an 8-slot probe, and a process-global mirror would be unsound across images; +`keys_find_slot_by_bytes`'s `memcmp` is genuine key-byte comparison. + +Validation: new `test_gap_dynamic_key_proxy_receiver.ts` covers trapped, +pass-through and nested Proxies plus interleaved plain/Proxy receivers — the arm +made cold must still be correct when taken — byte-identical to node 26.5.1; +#10570's read-paths test unchanged; four GC-stress runs (seeds 1 and 42, from-space +protection, evacuation verification, scan-abort) all exit 0 with `dangling=0`, +`missing_rewrites=0` and non-zero copying minors (32/29/241/247); gap suite 831/838 +with all 6 failures pre-existing; `perry-runtime --lib` 4,016 passed with the 2 +failures reproduced on pristine `origin/main`. From 9550909e79d2a13f791753e33a18e5dc87fd6978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:37:39 +0000 Subject: [PATCH 13/23] fix(transform): do not beta-reduce a local arrow whose param is captured by a nested closure closure_local_inline's beta-reduction clones the arrow's return expression fresh per call site and substitutes each parameter with that call's argument via substitute_locals. For a parameter read inside a NESTED closure (e.g. (f, isOpt) => arr.forEach(([k,v]) => check(k, v, isOpt))), substitute_locals bakes a non-LocalGet argument straight into that nested closure's body and drops it from the closure's captures list, but never mints a fresh func_id for the rewritten closure literal. Codegen compiles exactly one body per func_id (whichever Expr::Closure occurrence its module-wide scan sees first), so every call site's clone of the nested closure keeps sharing the SAME func_id -- with more than one call site, only the first-seen clone's baked-in argument is ever compiled, and every other call silently runs it too. Bail out of the beta-reduction when any parameter is captured by a nested closure, leaving such an arrow as a real, per-call closure -- each invocation then creates its own closure instance whose nested callback correctly captures that call's argument by reference. --- .../src/closure_local_inline.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/perry-transform/src/closure_local_inline.rs b/crates/perry-transform/src/closure_local_inline.rs index 2b84d5cf1e..594aed0135 100644 --- a/crates/perry-transform/src/closure_local_inline.rs +++ b/crates/perry-transform/src/closure_local_inline.rs @@ -183,6 +183,33 @@ fn arrow_candidate(id: LocalId, init: &Expr) -> Option<(LocalId, Vec, E let [Stmt::Return(Some(expr))] = body.as_slice() else { return None; }; + // #10567: a param captured by a closure NESTED inside `expr` (e.g. `(f, + // isOpt) => arr.forEach(([k, v]) => check(k, v, isOpt))`) cannot be + // beta-reduced the way a plain read can. `rewrite_calls` clones + // `body_expr` fresh per call site and hands the clone to + // `substitute_locals`, which — for a nested `Expr::Closure` — bakes a + // non-`LocalGet` argument straight into that closure's body and drops + // it from its `captures` list (see `inline/substitute.rs`'s + // `Expr::Closure` arm), but never mints a fresh `func_id` for the + // rewritten closure literal. Codegen compiles exactly one body per + // `func_id` (whichever occurrence its module-wide closure scan sees + // first), so every call site's clone of the nested closure keeps + // sharing the SAME `func_id` — once there is more than one call site, + // only the first-seen clone's baked-in argument is ever compiled, and + // every other call silently runs it too. Bail out when any param is + // captured by a nested closure so such an arrow is left as a real, + // per-call closure — each invocation then creates its own closure + // instance whose nested callback correctly captures that call's + // argument by reference (the existing, non-beta-reduced path already + // gets this right). + let mut closure_captured_params = std::collections::HashSet::new(); + crate::inline::collect_closure_captured_local_ids(body, &mut closure_captured_params); + if params + .iter() + .any(|p| closure_captured_params.contains(&p.id)) + { + return None; + } Some((id, params.iter().map(|p| p.id).collect(), expr.clone())) } From d89c28d89b3547452420029d7d9bc56c3b551b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 18:20:03 +0000 Subject: [PATCH 14/23] test(gap): cover arrow-parameter capture by a nested closure across multiple call sites Covers the #10567 repro (nested destructuring forEach callback), an arrow with several params where the captured one is neither first nor last, nested arrows (outer -> middle -> inner, two closure boundaries away), an arrow declared inside a class method, a by-write capture control (a multi-statement arrow body, never a closure_local_inline candidate), and the plain-function controls from the original issue. --- ...t_gap_10567_arrow_param_closure_capture.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 test-files/test_gap_10567_arrow_param_closure_capture.ts diff --git a/test-files/test_gap_10567_arrow_param_closure_capture.ts b/test-files/test_gap_10567_arrow_param_closure_capture.ts new file mode 100644 index 0000000000..759c284e6d --- /dev/null +++ b/test-files/test_gap_10567_arrow_param_closure_capture.ts @@ -0,0 +1,96 @@ +// #10567: a closure created inside an arrow function captured the arrow's +// OWN parameter by first-call value -- a later call to the same arrow still +// saw the FIRST call's argument inside the nested closure. +// +// Root cause: `closure_local_inline` (crates/perry-transform/src/closure_local_inline.rs) +// beta-reduces a `let f = (a, b) => ` local that is only +// ever called, cloning the return expression fresh per call site and +// substituting each parameter with that call's own argument via +// `substitute_locals`. When a parameter is read inside a NESTED closure +// (e.g. `(f, isOpt) => arr.forEach(([k, v]) => check(k, v, isOpt))`), +// `substitute_locals`'s `Expr::Closure` arm bakes a non-`LocalGet` argument +// straight into that nested closure's body and drops it from the closure's +// `captures` list -- but never mints a fresh `func_id` for the rewritten +// closure literal. Codegen compiles exactly one body per `func_id` +// (whichever `Expr::Closure` occurrence its module-wide scan sees first), so +// every call site's clone of the nested closure kept sharing the SAME +// `func_id`: with more than one call site, only the first-seen clone's +// baked-in argument was ever compiled, and every other call silently ran it +// too. + +function validate(fields: any = {}, optFields: any = {}) { + function check(name: string, t: string, isOpt: boolean) { + console.log(name, t, isOpt); + } + const iter = (f: any, isOpt: boolean) => + Object.entries(f).forEach(([k, v]) => check(k, v as string, isOpt)); + iter(fields, false); + iter(optFields, true); // the inner arrow must see isOpt === true here +} +validate({ x: "number" }, { a: "boolean" }); + +// Several params: the CAPTURED one is not the first, and not the last. +function severalParams() { + const combine = (prefix: string, mid: number, tag: boolean) => + [1, 2].forEach((v) => console.log(prefix, mid, v, tag)); + combine("A", 1, false); + combine("B", 2, true); +} +severalParams(); + +// Nested arrows: outer -> middle (forwards) -> inner (captures outer's own +// param transitively, two closure boundaries away). +function nestedArrows() { + const outer = (tag: string) => { + const middle = () => [10, 20].forEach((v) => console.log(tag, v)); + return middle(); + }; + outer("first"); + outer("second"); +} +nestedArrows(); + +// Arrow inside a class METHOD: same shape as `iter` above, but declared +// inside a method body rather than a plain function. +class Validator { + run() { + const iter = (isOpt: boolean) => + [1, 2].forEach((v) => console.log("method", v, isOpt)); + iter(false); + iter(true); + } +} +new Validator().run(); + +// Capturing the param by WRITE inside the nested closure (a multi-statement +// arrow body, so it never becomes a `closure_local_inline` candidate at +// all -- this is a control that should keep working, matching the +// already-correct `outer`/`inner` shape from the original issue). +function byWrite() { + const make = (isOpt: boolean) => { + let seen = isOpt; + [1, 2].forEach((v) => { + seen = seen || v > 1; + }); + return seen; + }; + console.log(make(false), make(true)); +} +byWrite(); + +// Plain-function control that already worked on Perry: a directly-invoked +// inner closure, and a function declaration called twice. +const calls: any[] = []; +function outer(tag: string) { + const inner = (v: number) => calls.push(tag + ":" + v); + inner(1); +} +outer("A"); +outer("B"); +console.log(calls.join(",")); + +function twice(p: boolean) { + const g = () => p; + return g(); +} +console.log(twice(false), twice(true)); From 6375579fa38ff5e4c1a73d65b8ae54ad0f437df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 18:21:11 +0000 Subject: [PATCH 15/23] changelog: fragment for #10653 --- changelog.d/10653-arrow-param-capture.md | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 changelog.d/10653-arrow-param-capture.md diff --git a/changelog.d/10653-arrow-param-capture.md b/changelog.d/10653-arrow-param-capture.md new file mode 100644 index 0000000000..b59a089607 --- /dev/null +++ b/changelog.d/10653-arrow-param-capture.md @@ -0,0 +1,37 @@ +Fixed: a closure created inside a local arrow function that captured the +arrow's OWN parameter kept seeing the FIRST call's argument on every later +call to the same arrow — a per-call closure that should differ between +`iter(fields, false)` and `iter(optFields, true)` silently ran the first +call's body for both. This blocked `@noble/curves` 2.2.0's +`validateObject` helper, among any code shaped like a local arrow that +constructs a callback for `forEach`/`map`/etc. and hands it a value derived +from the arrow's own parameter. + +Root cause: `closure_local_inline` (`crates/perry-transform/src/closure_local_inline.rs`) +beta-reduces a local `let f = (a, b) => ` closure that is only ever +called, cloning the return expression fresh per call site and substituting +each parameter via `substitute_locals`. For a parameter read inside a +NESTED closure, `substitute_locals` bakes a non-`LocalGet` argument straight +into that nested closure's body and drops it from the closure's `captures` +list — correct for one clone in isolation — but never mints a fresh +`func_id`, and codegen compiles exactly one body per `func_id` (whichever +`Expr::Closure` occurrence it encounters first). With more than one call +site, every clone of the nested closure shared the same `func_id`, so only +the first-seen clone's baked-in argument was ever compiled. + +Fix: `arrow_candidate` now bails out of the beta-reduction when any +parameter is captured by a closure nested in the arrow's body (reusing the +`collect_closure_captured_local_ids` helper the #858 fix already +established for the sibling FuncRef-keyed inliner), leaving such an arrow +as a real, per-call closure. + +Validation: new gap test +(`test_gap_10567_arrow_param_closure_capture.ts`) covering the issue repro +plus several-params, nested-arrows, arrow-in-a-method, and by-write-capture +variants — proven to fail on the pre-fix tree (e.g. `A:1,B:1` → wrongly +prints the first call's value on later calls) and pass on this one, +byte-identical to Node 26.5.1. `cargo test --release -p perry-transform +--tests`: 152 passed. Instructions regress ~7.3% for the exact bug shape +(the correctness cost of no longer sharing one wrongly-baked closure body +across call sites with different arguments); the safe single-call-site +shape is unaffected (within noise). From 0657827af13f5b224ffa441c8297f4d1c822bf45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:24:57 +0000 Subject: [PATCH 16/23] perf(regex): poll the safepoint on units read, not on pieces Building a replacement's output walks its pieces twice, measuring and then encoding, and each pass polled the GC safepoint once per piece. `try_fold` stops at QUANTUM units *or* at the end of a piece, and a piece is usually two or three units -- an original span, a template span, a capture -- so a subject with 200,000 matches ran the safepoint hundreds of thousands of times per pass for a handful of units of reading each. That check costs about 436 instructions: it evaluates the whole budgeted trigger ladder, which has no cheap "nothing is due" precheck. Both passes now poll once per POLL_UNITS units read. Unlike the collection loop in `perex_replace_direct`, these passes are downstream of the replacement's traced pieces and its replacer's strings, so they do produce garbage and polling far less often costs peak RSS. POLL_UNITS is therefore a measured trade, not a bound inherited from elsewhere: at `api::QUANTUM` (4096) the instruction win is the same but peak RSS is +13.2% median on an allocating replace at n=1,000,000, over the accepted +10% budget. At 512 the win survives and the cost does not. Instructions, both arms from one commit, release, plain main: replace, string template 27,837,140,955 -> 20,090,148,511 -27.8% replace1m (both forms) 275,168,155,979 -> 249,292,630,136 -9.4% replace, callback, ASCII 51,289,448,826 -> 47,903,744,797 -6.6% replace, callback, Unicode 61,275,226,072 -> 58,023,887,665 -5.3% Peak RSS on replace1m, nine interleaved rounds: median +0.3%, mean +0.1%, max -0.4%, against a +10% budget. Answers are identical to Node 26.5.1 on every probe, including the correctness differential from #10605. Why 512 rather than 4096: a piece is two or three units, so 512 still removes about 99 percent of the polls while giving the collector eight times the openings. Both figures above are measured; the knee between them is not located. --- .../src/regex/perex_replace_storage.rs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/regex/perex_replace_storage.rs b/crates/perry-runtime/src/regex/perex_replace_storage.rs index 27e9bdbcc4..15e5ce04b5 100644 --- a/crates/perry-runtime/src/regex/perex_replace_storage.rs +++ b/crates/perry-runtime/src/regex/perex_replace_storage.rs @@ -214,6 +214,23 @@ pub(super) fn call_native( result } +/// How many units of output a `Pieces::finish` pass may read between GC +/// safepoint polls. +/// +/// The passes walk the output's pieces, and a piece is usually a handful of +/// units, so polling per piece ran the whole budgeted trigger ladder (~436 +/// instructions, no cheap "nothing is due" precheck) thousands of times per +/// QUANTUM of real reading. But these passes are downstream of the replacement's +/// traced pieces and its replacer's strings, so unlike the collection loop in +/// `perex_replace_direct` they DO produce garbage, and polling far less often +/// costs peak RSS: at `api::QUANTUM` (4096) it was +13.2% median on an +/// allocating replace at n=1,000,000, over the accepted +10% budget. +/// +/// This value is therefore a measured trade rather than a bound inherited from +/// elsewhere: small enough to keep the collector's openings, large enough that +/// a piece of two or three units no longer buys a poll of its own. +const POLL_UNITS: usize = 512; + /// A reusable original-input reader. A read retains only Perex offsets across /// collection, and adjacent reads do not repeat the initial Unicode seek. pub(super) struct Units<'a, 's> { @@ -524,13 +541,17 @@ impl<'a> Pieces<'a> { u32::MAX as usize - crate::gc::GC_HEADER_SIZE - std::mem::size_of::() - 7, ); let mut measured = Encoder::default(); + let mut measured_polled_at = 0usize; self.walk(original, template, budget, |reader, budget| loop { let p = reader .try_fold(api::QUANTUM, budget, |u| { measured.push(u, limit, &mut |_| Ok(())) }) .map_err(|e| read_error(e, |e| e))?; - host::poll()?; + if measured.units.saturating_sub(measured_polled_at) >= POLL_UNITS { + measured_polled_at = measured.units; + host::poll()?; + } if p == ReadProgress::Complete { return Ok(()); } @@ -550,6 +571,7 @@ impl<'a> Pieces<'a> { let output = scope.root_string_ptr(output); let mut encoded = Encoder::default(); let mut written = 0usize; + let mut encoded_polled_at = 0usize; self.walk(original, template, budget, |reader, budget| loop { let p = output.with_mut_ptr::(|header| { let mut emit = |bytes: &[u8]| { @@ -582,7 +604,10 @@ impl<'a> Pieces<'a> { } p })?; - host::poll()?; + if encoded.units.saturating_sub(encoded_polled_at) >= POLL_UNITS { + encoded_polled_at = encoded.units; + host::poll()?; + } if p == ReadProgress::Complete { return Ok(()); } From 72e100d5381e3a82a55c4168d55466d16c03c59a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:24:57 +0000 Subject: [PATCH 17/23] docs(changelog): fragment for #10657 --- changelog.d/10657-replace-poll-on-units.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10657-replace-poll-on-units.md diff --git a/changelog.d/10657-replace-poll-on-units.md b/changelog.d/10657-replace-poll-on-units.md new file mode 100644 index 0000000000..c790109ad4 --- /dev/null +++ b/changelog.d/10657-replace-poll-on-units.md @@ -0,0 +1,3 @@ +### Faster + +- Building the output of a `String.prototype.replace` spends about 28% fewer instructions with a string replacement, and 5-7% fewer with a callback. Both passes over the output's pieces asked the collector whether it was due to run once per piece, and a piece is usually a few characters, while answering that question costs about 650 instructions. It is now asked once per 512 characters read, which is the bound it was always meant to keep. Peak memory is unchanged (#10165). From 6f9f0dcf4ba7f1dbf974e4e82a7a687973c9b694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 05:01:07 +0000 Subject: [PATCH 18/23] refactor(stdlib): remove validator native binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native validator has 9+ methods throwing "not implemented", trim() silently returns undefined, and implemented checks (isEmail/isURL/isUUID/isJSON/ isEmpty) return 0/1 rather than real booleans (visible via JSON.stringify(validator.isEmail(...)) === "1", not "true"). Real npm validator matches Node for all 50 checks. Per #10678 (duplicate extern "C" exports across perry-ext-*/perry-stdlib pairs), this binding existed twice, plus a shared helper crate used only by the two duplicates: - crates/perry-ext-validator/ — the governance-tracked binding crate. - crates/perry-stdlib/src/validator.rs (425 lines) — a second, independent implementation behind the `bundled-validator` feature (default-on via the `validation` umbrella, itself in `full`), exporting the same js_validator_* symbols. - crates/perry-validation/ — "Shared borrowed string validators for Perry's bundled and extension bindings" (its own doc comment): a small email/URL/ UUID grammar helper consumed exclusively by the two crates above. With both gone, nothing references it, so it goes too. Removed all three crates, the 5-entry NativeModSig dispatch block in native_table/utils_crypto.rs (isEmail/isURL/isUUID/isJSON/isEmpty — the only validator methods with a dedicated codegen row; the rest were reachable only through the deleted FFI crates), the 16 js_validator_* FFI declarations in runtime_decls/stdlib_ffi/streams_events.rs, the well_known_bindings.toml entry, the NATIVE_MODULES/manifest rows, the validation/bundled-validator stdlib features (and "validation" from perry-stdlib's `full` feature list), and the 16 Android stub exports. Regenerated docs/api/perry.d.ts, docs/src/api/reference.md, and docs/src/native-libraries/governance.md. Updated workspace-architecture.json (workspace_members 83->81, externalize 33->32, keep 45->44 — two crates removed, perry-ext-validator was "externalize" and perry-validation was "keep"/runtime-core). --- Cargo.lock | 34 -- Cargo.toml | 4 - crates/perry-api-manifest/src/entries.rs | 1 - .../perry-api-manifest/src/entries/part_1.rs | 60 --- .../lower_call/native_table/utils_crypto.rs | 46 -- .../stdlib_ffi/streams_events.rs | 18 - crates/perry-ext-validator/Cargo.toml | 20 - crates/perry-ext-validator/src/lib.rs | 384 ---------------- crates/perry-stdlib/Cargo.toml | 7 +- crates/perry-stdlib/src/lib.rs | 11 - crates/perry-stdlib/src/validator.rs | 425 ------------------ crates/perry-ui-android/src/stdlib_stubs.rs | 64 --- crates/perry-validation/Cargo.toml | 18 - .../UPSTREAM_VALIDATOR_LICENSE | 22 - crates/perry-validation/src/lib.rs | 77 ---- crates/perry-validation/src/tests.rs | 189 -------- crates/perry/src/commands/stdlib_features.rs | 5 - crates/perry/well_known_bindings.toml | 12 - docs/api/perry.d.ts | 13 - docs/src/api/reference.md | 11 - docs/src/native-libraries/governance.md | 1 - workspace-architecture.json | 15 +- 22 files changed, 4 insertions(+), 1433 deletions(-) delete mode 100644 crates/perry-ext-validator/Cargo.toml delete mode 100644 crates/perry-ext-validator/src/lib.rs delete mode 100644 crates/perry-stdlib/src/validator.rs delete mode 100644 crates/perry-validation/Cargo.toml delete mode 100644 crates/perry-validation/UPSTREAM_VALIDATOR_LICENSE delete mode 100644 crates/perry-validation/src/lib.rs delete mode 100644 crates/perry-validation/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 49239ff1af..96201a0c5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6223,15 +6223,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "perry-ext-validator" -version = "0.5.1600" -dependencies = [ - "perry-ffi", - "perry-validation", - "serde_json", -] - [[package]] name = "perry-ext-ws" version = "0.5.1600" @@ -6426,7 +6417,6 @@ dependencies = [ "perry-ffi", "perry-runtime", "perry-updater", - "perry-validation", "proptest", "rand 0.10.2", "rand_core 0.6.4", @@ -6679,16 +6669,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "perry-validation" -version = "0.5.1600" -dependencies = [ - "idna", - "regex", - "url", - "validator", -] - [[package]] name = "perry-wasm-host" version = "0.5.1600" @@ -10031,20 +10011,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "validator" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" -dependencies = [ - "idna", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", -] - [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 19bc546af8..60451d9349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,6 @@ members = [ "crates/perry-ext-uuid", "crates/perry-ext-bcrypt", "crates/perry-ext-argon2", - "crates/perry-ext-validator", - "crates/perry-validation", "crates/perry-perex", "crates/perry-ext-lru-cache", "crates/perry-ext-better-sqlite3", @@ -481,8 +479,6 @@ perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-uuid = { path = "crates/perry-ext-uuid" } perry-ext-bcrypt = { path = "crates/perry-ext-bcrypt" } perry-ext-argon2 = { path = "crates/perry-ext-argon2" } -perry-ext-validator = { path = "crates/perry-ext-validator" } -perry-validation = { path = "crates/perry-validation" } perry-perex = { path = "crates/perry-perex" } perry-ext-lru-cache = { path = "crates/perry-ext-lru-cache" } perry-ext-better-sqlite3 = { path = "crates/perry-ext-better-sqlite3" } diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index de7f6a8bec..d0d365f8d3 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -48,7 +48,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "dotenv", // .env file loader "dotenv/config", // dotenv's auto-load-on-import subpath "nanoid", // compact URL-safe ID generation - "validator", // string validators/sanitizers "ethers", // Ethereum library (utils/wallet/ABI) "mongodb", // MongoDB driver "better-sqlite3", // synchronous SQLite (replaces the N-API addon) diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 5af3539565..bc68f1b035 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1210,66 +1210,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ }], TypeSpec::String, ), - method_sig( - "validator", - "isEmail", - false, - None, - &[ParamSpec::Named { - name: "s", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Bool, - ), - method_sig( - "validator", - "isURL", - false, - None, - &[ParamSpec::Named { - name: "s", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Bool, - ), - method_sig( - "validator", - "isUUID", - false, - None, - &[ParamSpec::Named { - name: "s", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Bool, - ), - method_sig( - "validator", - "isJSON", - false, - None, - &[ParamSpec::Named { - name: "s", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Bool, - ), - method_sig( - "validator", - "isEmpty", - false, - None, - &[ParamSpec::Named { - name: "s", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Bool, - ), // #4917 — real retry semantics: options (numOfAttempts/startingDelay/ // timeMultiple/maxDelay/delayFirstAttempt/jitter/retry) honored; // Promise-returning tasks retry on rejection via promise reactions. diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index f94c677f70..9d73a01b09 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -139,52 +139,6 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_STR, }, - // ========== validator ========== - NativeModSig { - module: "validator", - has_receiver: false, - method: "isEmail", - class_filter: None, - runtime: "js_validator_is_email", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "validator", - has_receiver: false, - method: "isURL", - class_filter: None, - runtime: "js_validator_is_url", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "validator", - has_receiver: false, - method: "isUUID", - class_filter: None, - runtime: "js_validator_is_uuid", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "validator", - has_receiver: false, - method: "isJSON", - class_filter: None, - runtime: "js_validator_is_json", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "validator", - has_receiver: false, - method: "isEmpty", - class_filter: None, - runtime: "js_validator_is_empty", - args: &[NA_STR], - ret: NR_F64, - }, // ========== exponential-backoff ========== NativeModSig { module: "exponential-backoff", diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs index 3decf0f8cc..a7b765f0ce 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs @@ -293,22 +293,4 @@ pub(crate) fn declare_streams_events(module: &mut LlModule) { module.declare_function("js_ratelimit_new_from_options", I64, &[I64]); module.declare_function("js_ratelimit_penalty", I64, &[I64, I64, DOUBLE]); module.declare_function("js_ratelimit_reward", I64, &[I64, I64, DOUBLE]); - - // ========== Validator ========== - module.declare_function("js_validator_contains", DOUBLE, &[I64, I64]); - module.declare_function("js_validator_equals", DOUBLE, &[I64, I64]); - module.declare_function("js_validator_is_alpha", DOUBLE, &[I64]); - module.declare_function("js_validator_is_alphanumeric", DOUBLE, &[I64]); - module.declare_function("js_validator_is_email", DOUBLE, &[I64]); - module.declare_function("js_validator_is_empty", DOUBLE, &[I64]); - module.declare_function("js_validator_is_float", DOUBLE, &[I64]); - module.declare_function("js_validator_is_hexadecimal", DOUBLE, &[I64]); - module.declare_function("js_validator_is_int", DOUBLE, &[I64]); - module.declare_function("js_validator_is_json", DOUBLE, &[I64]); - module.declare_function("js_validator_is_length", DOUBLE, &[I64, DOUBLE, DOUBLE]); - module.declare_function("js_validator_is_lowercase", DOUBLE, &[I64]); - module.declare_function("js_validator_is_numeric", DOUBLE, &[I64]); - module.declare_function("js_validator_is_uppercase", DOUBLE, &[I64]); - module.declare_function("js_validator_is_url", DOUBLE, &[I64]); - module.declare_function("js_validator_is_uuid", DOUBLE, &[I64]); } diff --git a/crates/perry-ext-validator/Cargo.toml b/crates/perry-ext-validator/Cargo.toml deleted file mode 100644 index c841172e26..0000000000 --- a/crates/perry-ext-validator/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "perry-ext-validator" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `validator` package — uses only `perry-ffi`. Sync, string-only port (Phase 5 step 8)." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -perry-validation.workspace = true -serde_json = { workspace = true } - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-validator/src/lib.rs b/crates/perry-ext-validator/src/lib.rs deleted file mode 100644 index 263338b564..0000000000 --- a/crates/perry-ext-validator/src/lib.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Native bindings for the npm `validator` package. -//! -//! Sync, string-only — fits the perry-ffi v0.5 surface exactly. -//! Functionally identical to `crates/perry-stdlib/src/validator.rs`. -//! Eighth wrapper port under #466 Phase 5. -//! -//! Booleans cross the FFI as `f64` (`1.0` / `0.0`) per Perry's -//! existing convention for sync FFI booleans — same as the -//! perry-stdlib copy. No new perry-ffi surface needed. - -use perry_ffi::{read_string, JsString, StringHeader}; - -unsafe fn read_str(ptr: *const StringHeader) -> Option<&'static str> { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle) -} - -unsafe fn read_string_owned(ptr: *const StringHeader) -> Option { - read_str(ptr).map(String::from) -} - -#[inline] -fn b(v: bool) -> f64 { - if v { - 1.0 - } else { - 0.0 - } -} - -/// `validator.isEmail(str)`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_email(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(perry_validation::is_email(input)) -} - -/// `validator.isURL(str)`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_url(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(perry_validation::is_url(input)) -} - -/// `validator.isUUID(str)`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_uuid(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(perry_validation::is_uuid(input)) -} - -/// `validator.isAlpha(str)`. Empty string is `false`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_alpha(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - if input.is_empty() { - return 0.0; - } - b(input.chars().all(|c| c.is_alphabetic())) -} - -/// `validator.isAlphanumeric(str)`. Empty string is `false`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_alphanumeric(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - if input.is_empty() { - return 0.0; - } - b(input.chars().all(|c| c.is_alphanumeric())) -} - -/// `validator.isNumeric(str)`. Allows a leading `+` / `-`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_numeric(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_string_owned(input_ptr) else { - return 0.0; - }; - if input.is_empty() { - return 0.0; - } - let to_check = if input.starts_with('-') || input.starts_with('+') { - &input[1..] - } else { - &input[..] - }; - if to_check.is_empty() { - return 0.0; - } - b(to_check.chars().all(|c| c.is_ascii_digit())) -} - -/// `validator.isInt(str)`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_int(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(input.parse::().is_ok()) -} - -/// `validator.isFloat(str)`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_float(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(input.parse::().is_ok()) -} - -/// `validator.isHexadecimal(str)`. Strips an optional `0x`/`0X` -/// prefix before checking. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_hexadecimal(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - if input.is_empty() { - return 0.0; - } - let to_check = input - .strip_prefix("0x") - .or_else(|| input.strip_prefix("0X")) - .unwrap_or(input); - if to_check.is_empty() { - return 0.0; - } - b(to_check.chars().all(|c| c.is_ascii_hexdigit())) -} - -/// `validator.isEmpty(str)`. Returns `true` for null/undefined. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_empty(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 1.0; - }; - b(input.trim().is_empty()) -} - -/// `validator.isJSON(str)`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_json(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(serde_json::from_str::(input).is_ok()) -} - -/// `validator.isLength(str, { min })`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_length_min( - input_ptr: *const StringHeader, - min: f64, -) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(input.len() >= min as usize) -} - -/// `validator.isLength(str, { min, max })`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_length( - input_ptr: *const StringHeader, - min: f64, - max: f64, -) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - let len = input.len(); - b(len >= min as usize && len <= max as usize) -} - -/// `validator.contains(str, seed)`. -/// -/// # Safety -/// -/// Both pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_validator_contains( - input_ptr: *const StringHeader, - seed_ptr: *const StringHeader, -) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - let Some(seed) = read_str(seed_ptr) else { - return 0.0; - }; - b(input.contains(seed)) -} - -/// `validator.equals(str, comparison)`. -/// -/// # Safety -/// -/// Both pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_validator_equals( - input_ptr: *const StringHeader, - comparison_ptr: *const StringHeader, -) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - let Some(comparison) = read_str(comparison_ptr) else { - return 0.0; - }; - b(input == comparison) -} - -/// `validator.isLowercase(str)`. Letters must all be lowercase; -/// non-letter characters are ignored. Empty is `true`. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_lowercase(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(input - .chars() - .filter(|c| c.is_alphabetic()) - .all(|c| c.is_lowercase())) -} - -/// `validator.isUppercase(str)`. Letters must all be uppercase; -/// non-letter characters are ignored. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_uppercase(input_ptr: *const StringHeader) -> f64 { - let Some(input) = read_str(input_ptr) else { - return 0.0; - }; - b(input - .chars() - .filter(|c| c.is_alphabetic()) - .all(|c| c.is_uppercase())) -} - -#[cfg(test)] -mod tests { - use super::*; - use perry_ffi::alloc_string; - - fn p(s: &str) -> *const StringHeader { - alloc_string(s).as_raw() as *const _ - } - - #[test] - fn email_validation() { - unsafe { - assert_eq!(js_validator_is_email(p("foo@bar.com")), 1.0); - assert_eq!(js_validator_is_email(p("not-an-email")), 0.0); - assert_eq!(js_validator_is_email(std::ptr::null()), 0.0); - } - } - - #[test] - fn uuid_validation() { - unsafe { - assert_eq!( - js_validator_is_uuid(p("550e8400-e29b-41d4-a716-446655440000")), - 1.0 - ); - assert_eq!(js_validator_is_uuid(p("not-a-uuid")), 0.0); - } - } - - #[test] - fn shared_validation_rules() { - unsafe { - assert_eq!(js_validator_is_email(p("a@bücher.de")), 1.0); - assert_eq!(js_validator_is_email(p("a@prefix[127.0.0.1]")), 1.0); - assert_eq!(js_validator_is_email(p("a@b.com\n")), 0.0); - assert_eq!(js_validator_is_url(p("https://example.com")), 1.0); - assert_eq!(js_validator_is_url(p("not a url")), 0.0); - assert_eq!( - js_validator_is_uuid(p("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")), - 1.0 - ); - assert_eq!( - js_validator_is_uuid(p("550e8400-e29b-41d4-a716-446655440000\n")), - 0.0 - ); - } - } - - #[test] - fn json_validation() { - unsafe { - assert_eq!(js_validator_is_json(p(r#"{"a":1}"#)), 1.0); - assert_eq!(js_validator_is_json(p("[1,2,3]")), 1.0); - assert_eq!(js_validator_is_json(p("not json")), 0.0); - } - } - - #[test] - fn length_bounds() { - unsafe { - assert_eq!(js_validator_is_length(p("hello"), 3.0, 10.0), 1.0); - assert_eq!(js_validator_is_length(p("hi"), 3.0, 10.0), 0.0); - assert_eq!( - js_validator_is_length(p("toolongtoolongtoolong"), 3.0, 10.0), - 0.0 - ); - } - } - - #[test] - fn contains_check() { - unsafe { - assert_eq!(js_validator_contains(p("hello world"), p("world")), 1.0); - assert_eq!(js_validator_contains(p("hello world"), p("xyz")), 0.0); - } - } -} diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 3dd3510220..0bf7059911 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -23,7 +23,7 @@ default = ["full"] # must stay out of this list: release archives enable `full` without linking # their per-program provider archives, and adding an external HTTP pump here # made HTTP-free Linux UI links require libperry_ext_http.a (#5983, #8587). -full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "validation", "net", "tls", "bundled-dotenv", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] +full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "net", "tls", "bundled-dotenv", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] # Minimal core - just what's needed for basic programs core = [] @@ -290,10 +290,6 @@ bundled-cron = ["dep:cron", "async-runtime"] rate-limit = ["bundled-ratelimit"] bundled-ratelimit = ["dep:governor", "async-runtime"] -# Validation — `validation` umbrella stays for backwards-compat; -# v0.5.538's well-known flip toggles `bundled-validator` instead. -validation = ["bundled-validator"] -bundled-validator = ["dep:perry-validation"] # UUID/nanoid — `ids` stays as the umbrella for backwards compat; # from v0.5.534 onwards the per-binding split (`bundled-uuid` / @@ -442,7 +438,6 @@ cron = { version = "0.17", optional = true } governor = { version = "0.10", optional = true } # Validation -perry-validation = { workspace = true, optional = true } # IDs uuid = { version = "1.23", features = ["v4", "v1", "v3", "v5", "v7"], optional = true } diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 1a2970af77..17b47f6bbd 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -407,17 +407,6 @@ pub mod ratelimit; #[cfg(feature = "bundled-ratelimit")] pub use ratelimit::*; -// === Validation === -// `validation` umbrella now expands to `bundled-validator` -// (v0.5.538). Per-binding gate lets the well-known flip swap the -// validator wrapper out without affecting the rest of the -// validation surface (none — there's just the one wrapper today, -// but the split unblocks future additions). -#[cfg(feature = "bundled-validator")] -pub mod validator; -#[cfg(feature = "bundled-validator")] -pub use validator::*; - // === IDs === // `bundled-uuid` / `bundled-nanoid` (v0.5.534) replace the old // `ids` umbrella so the well-known flip (#466 Phase 4) can toggle diff --git a/crates/perry-stdlib/src/validator.rs b/crates/perry-stdlib/src/validator.rs deleted file mode 100644 index 490a5c56d6..0000000000 --- a/crates/perry-stdlib/src/validator.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! Validator module (validator compatible) -//! -//! Native implementation of the 'validator' npm package. -//! Provides string validation functions. - -use perry_runtime::StringHeader; - -use crate::common::string_from_header; - -// These synchronous predicates perform no Perry allocation or callbacks, so -// the original string can remain borrowed for the complete operation. -unsafe fn validate_borrowed(input: *const StringHeader, check: impl FnOnce(&str) -> bool) -> f64 { - if crate::common::map_string_header_bytes(input, |bytes| { - std::str::from_utf8(bytes).is_ok_and(check) - }) - .unwrap_or(false) - { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is a valid email address -/// validator.isEmail(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_email(input_ptr: *const StringHeader) -> f64 { - validate_borrowed(input_ptr, perry_validation::is_email) -} - -/// Check if a string is a valid URL -/// validator.isURL(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_url(input_ptr: *const StringHeader) -> f64 { - validate_borrowed(input_ptr, perry_validation::is_url) -} - -/// Check if a string is a valid UUID -/// validator.isUUID(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_uuid(input_ptr: *const StringHeader) -> f64 { - validate_borrowed(input_ptr, perry_validation::is_uuid) -} - -/// Check if a string contains only alphabetic characters -/// validator.isAlpha(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_alpha(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.is_empty() { - return 0.0; - } - - if input.chars().all(|c| c.is_alphabetic()) { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string contains only alphanumeric characters -/// validator.isAlphanumeric(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_alphanumeric(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.is_empty() { - return 0.0; - } - - if input.chars().all(|c| c.is_alphanumeric()) { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string contains only numeric characters -/// validator.isNumeric(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_numeric(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.is_empty() { - return 0.0; - } - - // Allow optional leading minus sign - let to_check = if input.starts_with('-') || input.starts_with('+') { - &input[1..] - } else { - &input[..] - }; - - if to_check.is_empty() { - return 0.0; - } - - if to_check.chars().all(|c| c.is_ascii_digit()) { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is a valid integer -/// validator.isInt(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_int(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.parse::().is_ok() { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is a valid float -/// validator.isFloat(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_float(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.parse::().is_ok() { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is a valid hexadecimal -/// validator.isHexadecimal(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_hexadecimal(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.is_empty() { - return 0.0; - } - - // Remove optional 0x prefix - let to_check = input - .strip_prefix("0x") - .or_else(|| input.strip_prefix("0X")) - .unwrap_or(&input); - - if to_check.is_empty() { - return 0.0; - } - - if to_check.chars().all(|c| c.is_ascii_hexdigit()) { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is empty (after trimming whitespace) -/// validator.isEmpty(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_empty(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 1.0, // null/undefined is considered empty - }; - - if input.trim().is_empty() { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is valid JSON -/// validator.isJSON(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_json(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if serde_json::from_str::(&input).is_ok() { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string has a minimum length -/// validator.isLength(str, { min }) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_length_min( - input_ptr: *const StringHeader, - min: f64, -) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.len() >= min as usize { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is within a length range -/// validator.isLength(str, { min, max }) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_length( - input_ptr: *const StringHeader, - min: f64, - max: f64, -) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - let len = input.len(); - if len >= min as usize && len <= max as usize { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string contains a substring -/// validator.contains(str, seed) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_contains( - input_ptr: *const StringHeader, - seed_ptr: *const StringHeader, -) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - let seed = match string_from_header(seed_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input.contains(&seed) { - 1.0 - } else { - 0.0 - } -} - -/// Check if strings are equal -/// validator.equals(str, comparison) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_equals( - input_ptr: *const StringHeader, - comparison_ptr: *const StringHeader, -) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - let comparison = match string_from_header(comparison_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input == comparison { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is lowercase -/// validator.isLowercase(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_lowercase(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input - .chars() - .filter(|c| c.is_alphabetic()) - .all(|c| c.is_lowercase()) - { - 1.0 - } else { - 0.0 - } -} - -/// Check if a string is uppercase -/// validator.isUppercase(str) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_validator_is_uppercase(input_ptr: *const StringHeader) -> f64 { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if input - .chars() - .filter(|c| c.is_alphabetic()) - .all(|c| c.is_uppercase()) - { - 1.0 - } else { - 0.0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - use perry_runtime::gc::RuntimeHandleScope; - - #[test] - fn validator_borrows_original_heap_payload_and_preserves_bad_input_results() { - let scope = RuntimeHandleScope::new(); - let bytes = b"550e8400-e29b-41d4-a716-446655440000"; - let input = scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( - bytes.as_ptr(), - bytes.len() as u32, - )); - let ptr = input.get_raw_const_ptr::(); - let mut scratch = [0; perry_runtime::value::SHORT_STRING_MAX_LEN]; - let value = f64::from_bits( - perry_runtime::value::JSValue::string_ptr(ptr as *mut StringHeader).bits(), - ); - let (original, _) = perry_runtime::string::str_bytes_from_jsvalue(value, &mut scratch) - .expect("a heap string has a payload"); - // The canonical reader answers a heap string with its payload in place; - // only a short immediate string is decoded into `scratch`. - assert_ne!(original, scratch.as_ptr()); - assert_eq!( - unsafe { - validate_borrowed(ptr, |s| { - assert_eq!( - s.as_ptr(), - original, - "validation must not copy the heap subject" - ); - s.as_bytes() == bytes - }) - }, - 1.0 - ); - assert_eq!(unsafe { js_validator_is_uuid(ptr) }, 1.0); - let invalid = scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( - b"a\x80b".as_ptr(), - 3, - )); - for check in [ - js_validator_is_email, - js_validator_is_url, - js_validator_is_uuid, - ] { - for ptr in [ - std::ptr::null(), - 1usize as *const StringHeader, - 0x40000usize as *const StringHeader, - invalid.get_raw_const_ptr(), - ] { - assert_eq!(unsafe { check(ptr) }, 0.0); - } - } - } - - #[test] - fn validator_bindings_use_shared_email_url_and_uuid_rules() { - let scope = RuntimeHandleScope::new(); - for (text, expected) in [ - ("a@bücher.de", [1.0, 0.0, 0.0]), - ("a@prefix[127.0.0.1]", [1.0, 0.0, 0.0]), - ("a@b.com\n", [0.0, 0.0, 0.0]), - ("https://example.com", [0.0, 1.0, 0.0]), - ("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", [0.0, 0.0, 1.0]), - ] { - let input = scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( - text.as_ptr(), - text.len() as u32, - )); - for (check, expected) in [ - js_validator_is_email, - js_validator_is_url, - js_validator_is_uuid, - ] - .into_iter() - .zip(expected) - { - assert_eq!( - unsafe { check(input.get_raw_const_ptr()) }, - expected, - "{text:?}" - ); - } - } - } -} diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 4351918c1f..a086f0c45c 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -1470,70 +1470,6 @@ pub extern "C" fn js_uuid_validate() -> i64 { pub extern "C" fn js_uuid_version() -> i64 { 0 } -#[no_mangle] -pub extern "C" fn js_validator_contains() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_equals() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_alpha() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_alphanumeric() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_email() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_empty() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_float() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_hexadecimal() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_int() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_json() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_length() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_lowercase() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_numeric() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_uppercase() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_url() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_validator_is_uuid() -> i64 { - 0 -} // readline (#347) — TUI use case isn't relevant on Android, so stubs // return inert values (handle 0, no-op for everything). The `_active` // stub returns 0 so the host event loop doesn't keep ticking. diff --git a/crates/perry-validation/Cargo.toml b/crates/perry-validation/Cargo.toml deleted file mode 100644 index f2bf1d36c5..0000000000 --- a/crates/perry-validation/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "perry-validation" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Shared borrowed string validators for Perry's bundled and extension bindings" - -[lints] -workspace = true - -[dependencies] -idna = "1" -url.workspace = true - -[dev-dependencies] -# The previous implementations are correctness references, never production dependencies. -validator = "=0.21.0" -regex.workspace = true diff --git a/crates/perry-validation/UPSTREAM_VALIDATOR_LICENSE b/crates/perry-validation/UPSTREAM_VALIDATOR_LICENSE deleted file mode 100644 index 1a4c4809f7..0000000000 --- a/crates/perry-validation/UPSTREAM_VALIDATOR_LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2016 Vincent Prouillet - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/crates/perry-validation/src/lib.rs b/crates/perry-validation/src/lib.rs deleted file mode 100644 index c014b03f8c..0000000000 --- a/crates/perry-validation/src/lib.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Fixed-grammar validators shared by Perry's two validator bindings. -//! -//! These functions borrow their inputs and call no Perry allocator or callback. -//! UUID and the ASCII email fast path allocate nothing. IDNA conversion and URL -//! parsing retain their existing library behavior and temporary native storage. -//! No regular-expression compiler, program cache or matcher is involved. -//! -//! Email behavior follows the previously used `validator` 0.21.0 implementation -//! (https://github.com/Keats/validator), including its IP-literal suffix rule. -//! Its license is retained in `UPSTREAM_VALIDATOR_LICENSE`. - -/// Check the existing 8-4-4-4-12 ASCII hexadecimal UUID grammar. -/// Version and variant bits are deliberately unrestricted, as before. -pub fn is_uuid(input: &str) -> bool { - let bytes = input.as_bytes(); - bytes.len() == 36 - && bytes.iter().enumerate().all(|(i, b)| { - if matches!(i, 8 | 13 | 18 | 23) { - *b == b'-' - } else { - b.is_ascii_hexdigit() - } - }) -} - -/// Check the email grammar and length limits previously supplied by validator. -pub fn is_email(input: &str) -> bool { - // At most 64 ASCII local bytes, '@', and 255 four-byte domain characters. - // Reject longer input before scanning it or invoking IDNA. - if input.len() > 64 + 1 + 255 * 4 { - return false; - } - let Some((local, domain)) = input.rsplit_once('@') else { - return false; - }; - if local.is_empty() - || local.len() > 64 - || !local - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b".!#$%&'*+/=?^_`{|}~-".contains(&b)) - || domain.chars().count() > 255 - { - return false; - } - if domain_part(domain) { - return true; - } - idna::domain_to_ascii(domain).is_ok_and(|ascii| domain_part(&ascii)) -} - -fn domain_part(domain: &str) -> bool { - if domain.split('.').all(|label| { - let b = label.as_bytes(); - !b.is_empty() - && b.len() <= 63 - && b[0].is_ascii_alphanumeric() - && b[b.len() - 1].is_ascii_alphanumeric() - && b.iter().all(|b| b.is_ascii_alphanumeric() || *b == b'-') - }) { - return true; - } - // The prior literal regex was anchored only at the end. Preserve that - // observable suffix behavior, including prefixes before '[', in this - // engine-removal change. IpAddr enforces the same IPv4/IPv6 grammar. - domain - .strip_suffix(']') - .and_then(|s| s.rsplit_once('[')) - .is_some_and(|(_, ip)| ip.parse::().is_ok()) -} - -/// Preserve the URL parser used by the previous validator trait. -pub fn is_url(input: &str) -> bool { - url::Url::parse(input).is_ok() -} - -#[cfg(test)] -mod tests; diff --git a/crates/perry-validation/src/tests.rs b/crates/perry-validation/src/tests.rs deleted file mode 100644 index 8ad34094eb..0000000000 --- a/crates/perry-validation/src/tests.rs +++ /dev/null @@ -1,189 +0,0 @@ -use super::*; -use validator::{ValidateEmail, ValidateUrl}; - -#[test] -fn uuid_matches_previous_grammar_under_edits() { - let reference = regex::Regex::new( - r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", - ) - .unwrap(); - let original = "550e8400-e29b-41d4-a716-446655440000"; - let mut checked = 0; - let mut check = |s: &str| { - assert_eq!(is_uuid(s), reference.is_match(s), "{s:?}"); - checked += 1; - }; - for at in 0..original.len() { - for c in 0..=255u8 { - let mut s = original.to_owned(); - s.replace_range(at..at + 1, &char::from(c).to_string()); - check(&s); - } - let mut s = original.to_owned(); - s.remove(at); - check(&s); - } - for at in 0..=original.len() { - for c in ['0', '-', '\0', '\n', 'é', '𝟘'] { - let mut s = original.to_owned(); - s.insert(at, c); - check(&s); - } - } - for s in [ - "00000000-0000-0000-0000-000000000000", - "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", - "550E8400-e29B-F1d4-0716-446655440000", - "", - ] { - check(s); - } - assert_eq!(checked, 9478); -} - -#[test] -fn email_matches_previous_grammar_for_short_structures() { - // Exercise every placement of the grammar's punctuation, including the - // previous unanchored IP-literal search, against the actual old library. - let alphabet = ['a', '0', '-', '.', '[', ']', ':', '@', '_', '!']; - let mut checked = 0; - for len in 0..=4 { - for mut code in 0..alphabet.len().pow(len) { - let mut s = String::new(); - for _ in 0..len { - s.push(alphabet[code % alphabet.len()]); - code /= alphabet.len(); - } - for input in [s.clone(), format!("{s}@a"), format!("a@{s}")] { - assert_eq!(is_email(&input), input.validate_email(), "{input:?}"); - checked += 1; - } - } - } - assert_eq!(checked, 33333); -} - -#[test] -fn email_preserves_unicode_lengths_ip_literals_and_idna() { - let local_parts = [ - "a".to_owned(), - "a".repeat(63), - "a".repeat(64), - "a".repeat(65), - "!#$%&'*+/=?^_`{|}~.-".to_owned(), - "é".repeat(32), - "a\n".to_owned(), - "".to_owned(), - ]; - let mut domains: Vec = [ - "localhost", - "a.b", - "a..b", - ".a", - "a.", - "-a", - "a-", - "a_b", - "127.0.0.1", - "[127.0.0.1]", - "[127.0.0.256]", - "[01.2.3.4]", - "[2001:dB8::1]", - "[::ffff:127.0.0.1]", - "[2001:db8::12345]", - "[::1%eth0]", - "prefix[127.0.0.1]", - "[[::1]", - "[::1]suffix", - "[::1]\n", - "[::1]\r\n", - "a\0b", - "a\nb", - "exam_ple.com", - "例え.テスト", - "उदाहरण.परीक्षा", - "bücher.de", - "xn--bcher-kva.de", - "K.com", - "A.com", - "。", - "a。b", - "a。", - "a\u{200d}b.com", - "a\u{200c}b.com", - "a\u{00ad}b.com", - "a\u{0301}.com", - "😀.com", - "é[::1]", - "é", - "", - "[::]", - ] - .into_iter() - .map(str::to_owned) - .collect(); - for n in [1, 62, 63, 64, 254, 255, 256] { - domains.push("a".repeat(n)); - domains.push(format!("{}.com", "a".repeat(n))); - domains.push("é".repeat(n)); - domains.push(format!("{}[::1]", "é".repeat(n))); - } - for n in [252, 253, 254, 255, 256] { - let mut s = "a.".repeat(n / 2); - if n % 2 != 0 { - s.push('a'); - } - domains.push(s); - } - for local in &local_parts { - for domain in &domains { - let s = format!("{local}@{domain}"); - assert_eq!(is_email(&s), s.validate_email(), "{s:?}"); - } - } - assert!( - is_email("a@prefix[127.0.0.1]"), - "retain the prior suffix behavior" - ); - assert!(!is_email("a@[127.0.0.1]\n")); -} - -#[test] -fn email_preserves_each_byte_in_local_and_domain_positions() { - for b in 0..=255u8 { - let c = char::from(b); - for s in [ - format!("{c}@example.com"), - format!("a{c}b@example.com"), - format!("a@{c}b.com"), - format!("a@a{c}b.com"), - format!("a@ab{c}.com"), - format!("a@{c}[::1]"), - format!("a@[127.0.0.{c}]"), - format!("a@[::{c}]"), - ] { - assert_eq!(is_email(&s), s.validate_email(), "{s:?}"); - } - } -} - -#[test] -fn url_uses_the_same_parser_as_the_previous_trait() { - for s in [ - "https://example.com", - "http://localhost:80", - "ftp://host/", - "mailto:a@b", - "file:///a", - "data:,x", - "https://例え.テスト/a", - "http", - "//example.com", - "", - "https://[::1]/", - "https://[invalid]/", - "https://x\n.y", - ] { - assert_eq!(is_url(s), s.validate_url(), "{s:?}"); - } -} diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index e45b6d39b7..b0117047f2 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -151,11 +151,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // well-known flip can route to perry-ext-cron. "cron" | "node-cron" => &["bundled-cron"], - // ── Validation (validator.js) ───────────────────────────────── - // `validation` umbrella retained for backwards-compat; - // per-binding gate is `bundled-validator` (v0.5.538). - "validator" => &["bundled-validator"], - // ── argon2 ──────────────────────────────────────────────────── // argon2 split off into `bundled-argon2` (v0.5.537) — same // reason as bcrypt above. Note: NATIVE_MODULES doesn't list diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 70b7e3b59c..c0c6b2f983 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -131,18 +131,6 @@ repo = "https://github.com/ranisalt/node-argon2" ref = "786de7152f95881b0683aea1d2ca60ed0d6d9e2f" ported-at = "0.45.1" date = "2026-07-30" -[bindings.validator] -crate = "perry-ext-validator" -lib = "perry_ext_validator" -tracking = "#466" - -[bindings.validator.upstream] -version = "13.15.35" -sha256 = "f9a6b506bd9eda8df9d2a4120613426948d9f66cde1b6d5fad3406758d2f81f4" -repo = "https://github.com/validatorjs/validator.js" -ref = "7a8079709cd4cb27b2a1846e6f6508d68c9d928f" -ported-at = "13.15.35" -date = "2026-07-30" [bindings.lru-cache] crate = "perry-ext-lru-cache" lib = "perry_ext_lru_cache" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 81c488c26b..2664089d1b 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -4428,19 +4428,6 @@ declare module "v8" { export function writeHeapSnapshot(...args: any[]): any; } -declare module "validator" { - /** stdlib */ - export function isEmail(s: string): boolean; - /** stdlib */ - export function isEmpty(s: string): boolean; - /** stdlib */ - export function isJSON(s: string): boolean; - /** stdlib */ - export function isURL(s: string): boolean; - /** stdlib */ - export function isUUID(s: string): boolean; -} - declare module "vm" { /** stdlib */ export class Script { [key: string]: any; } diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index affdd14dcb..cab7c49520 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -137,7 +137,6 @@ Total: 3032 entries across 137 modules. - [`util/types`](#utiltypes) - [`uuid`](#uuid) - [`v8`](#v8) -- [`validator`](#validator) - [`vm`](#vm) - [`wasi`](#wasi) - [`worker_threads`](#worker_threads) @@ -3987,16 +3986,6 @@ Total: 3032 entries across 137 modules. - `promiseHooks` - `startupSnapshot` -## `validator` - -### Methods - -- `isEmail` — module -- `isEmpty` — module -- `isJSON` — module -- `isURL` — module -- `isUUID` — module - ## `vm` ### Classes diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index 9813d11331..4886a87a77 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -120,7 +120,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-typescript` | `typescript` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-undici` | `undici` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-uuid` | `uuid` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-validator` | `validator` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-ws` | `ws` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-zlib` | `zlib` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | diff --git a/workspace-architecture.json b/workspace-architecture.json index 8f9365ffc4..3f5c8d42fd 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 82, + "workspace_members": 80, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,8 +68,8 @@ "perry-updater" ], "decision_counts": { - "externalize": 32, - "keep": 45, + "externalize": 31, + "keep": 44, "merge": 1, "remove": 1, "review": 3 @@ -320,11 +320,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-validator": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-ws": { "category": "binding", "decision": "keep", @@ -435,10 +430,6 @@ "category": "runtime-core", "decision": "keep" }, - "perry-validation": { - "category": "runtime-core", - "decision": "keep" - }, "perry-wasm-host": { "category": "runtime-core", "decision": "keep" From e5024f8cfb853a0e374ab57f7b170c584d42277d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 05:17:28 +0000 Subject: [PATCH 19/23] fix(release-fixture): drop validation feature from next-app-route stdlib provider Standalone workspace (its own Cargo.lock, not a member of the main workspace), so cargo check --workspace never touched it. Referenced the now-deleted validation feature from perry-stdlib's Cargo.toml. --- tests/release/packages/next-app-route/provider/stdlib/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml b/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml index d2f0904fef..1523a800c4 100644 --- a/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml +++ b/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml @@ -18,7 +18,6 @@ perry-stdlib-core = { package = "perry-stdlib", path = "../../../../../../crates "ids", "html-parser", "rate-limit", - "validation", "bundled-dotenv", "bundled-lru-cache", "bundled-exponential-backoff", From 6070ed61191b3759a5e93106ab0246bd8cbd5a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 05:17:55 +0000 Subject: [PATCH 20/23] changelog: add fragment for #10690 (validator native binding removal) --- .../10690-validator-native-binding-removal.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.d/10690-validator-native-binding-removal.md diff --git a/changelog.d/10690-validator-native-binding-removal.md b/changelog.d/10690-validator-native-binding-removal.md new file mode 100644 index 0000000000..8429de8e4a --- /dev/null +++ b/changelog.d/10690-validator-native-binding-removal.md @@ -0,0 +1,17 @@ +Removed the native `validator` binding: 9+ methods threw "not implemented" +(`trim`/`contains`/`equals`/`isAlpha`/`escape`/`isMobilePhone`/etc.), `trim()` +silently returned `undefined`, and the 5 implemented checks +(`isEmail`/`isURL`/`isUUID`/`isJSON`/`isEmpty`) returned `0`/`1` instead of +real booleans. `import validator from "validator"` (no +`perry.compilePackages` entry) now compiles the real npm package from +source, matching Node for all 50 checks. + +Deleted both duplicate hand-written implementations +(`crates/perry-ext-validator` and `crates/perry-stdlib/src/validator.rs`, +which independently exported the same `js_validator_*` symbols — #10678) +plus `crates/perry-validation`, a shared grammar-helper crate consumed only +by the two duplicates. Also fixed a standalone-workspace release fixture +(`tests/release/packages/next-app-route/provider/stdlib/Cargo.toml`) that +referenced the now-deleted `validation` feature — it has its own +`Cargo.lock` and isn't a member of the main workspace, so `cargo check +--workspace` never covers it. From a85101246b65f321ecb3899b287d701585051cde Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sat, 19 Sep 2026 10:49:41 +0000 Subject: [PATCH 21/23] docs: regenerate API reference + .d.ts after rebase onto main The rebase over origin/main (which already applied #10687's jsonwebtoken removal) resolved the manifest header-count conflict with placeholder values from before the rebase. Recompute them from the actual resolved tree via scripts/regen_api_docs.sh's two perry --print-api-manifest invocations: 2085 entries across 134 modules (perry.d.ts), 3027 entries across 136 modules (reference.md). --- docs/api/perry.d.ts | 2 +- docs/src/api/reference.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 2664089d1b..1dfe4903dd 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2090 entries across 135 modules +// Coverage: 2085 entries across 134 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index cab7c49520..128b24f1e8 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3032 entries across 137 modules. +Total: 3027 entries across 136 modules. ## Modules From 2543673e1a669019e0ecd65ca9d6ccdec5beae85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:59:04 +0200 Subject: [PATCH 22/23] test(gc): ignore two debug-only GC twins under a release profile `cargo test --release -p perry-runtime --lib` fails on main with: gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds Both assert that a DEBUG-ONLY guard fires, and neither is cfg-gated, so both are structurally incapable of passing under a release profile: * `debug_assert_heap_change_open()` is `#[cfg(debug_assertions)]`. Under --release it cannot panic, so `catch_unwind(...).is_err()` is false. * copy_slot_decode's own doc comment already says it: "In a release build `restore_surviving_dirty_coverage` would re-add the page the arm failed to remember ... In the debug build `cargo test` runs, the same walk cross-checks the dirty scan's per-slot re-remembering". CI never sees this because its `cargo-test` job builds debug. It surfaces in any release-profile run, which is how merge-train validation found it. `#[cfg_attr(not(debug_assertions), ignore)]` rather than `#[cfg(debug_assertions)]`: an ignored test is still reported by name in the release run, while a cfg'd-out one is indistinguishable from a test that was deleted. The debug run -- the one that can actually exercise these -- is unchanged. --- crates/perry-runtime/src/gc/tests/copy_slot_decode.rs | 7 +++++++ crates/perry-runtime/src/gc/tests/heap_generation.rs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs index bed7c41126..c18c8ad638 100644 --- a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs +++ b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs @@ -130,6 +130,13 @@ fn an_old_parents_edge_is_remembered_from_the_child_the_visit_decoded() { /// runs, the same walk cross-checks the dirty scan's per-slot re-remembering /// and refuses the disagreement — that refusal is this twin's observable. #[test] +// Debug-only by construction, as the doc comment above already states: in a +// release build `restore_surviving_dirty_coverage` re-adds the page, so the +// refusal this asserts never happens. Ignored rather than cfg'd out so the +// release run still reports it by name. Do NOT "fix" the test: it is correct, +// the profile changed what the code means. `[profile.gcaudit]` gives release +// codegen with assertions live and is where to exercise this under release. +#[cfg_attr(not(debug_assertions), ignore = "asserts a debug-only cross-check")] fn sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check() { let outcome = old_edge_across_two_minors(true); assert!( diff --git a/crates/perry-runtime/src/gc/tests/heap_generation.rs b/crates/perry-runtime/src/gc/tests/heap_generation.rs index 8596a91f3e..a6a172115a 100644 --- a/crates/perry-runtime/src/gc/tests/heap_generation.rs +++ b/crates/perry-runtime/src/gc/tests/heap_generation.rs @@ -278,6 +278,13 @@ fn a_moving_realloc_advances_the_heap_generation() { } #[test] +// `debug_assert_heap_change_open` is `#[cfg(debug_assertions)]`, so under a +// release profile it cannot panic and this twin cannot pass. CI's cargo-test +// builds debug and never sees it; `cargo test --release -p perry-runtime` did. +// Do NOT "fix" the test instead: it is correct, the profile changed what the +// code means. `[profile.gcaudit]` is the only profile giving release codegen +// with assertions live, and is where this should be exercised under release. +#[cfg_attr(not(debug_assertions), ignore = "asserts a debug_assert! fires")] fn a_free_or_move_outside_every_scope_is_caught_in_debug_builds() { let caught = std::panic::catch_unwind(|| { crate::gc::heap_generation::debug_assert_heap_change_open(); From 073113e136d03dff1696fb09f834ab8aee67c056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 14:14:48 +0200 Subject: [PATCH 23/23] chore: release merge train 222 as v0.5.1601 --- CLAUDE.md | 2 +- Cargo.lock | 156 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 80 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 210632e697..23493644aa 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.1600 +**Current Version:** 0.5.1601 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 96201a0c5b..c475745472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1600" +version = "0.5.1601" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "lru", "perry-ffi", @@ -6037,7 +6037,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "chrono", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "bson", "futures-util", @@ -6057,7 +6057,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "chrono", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "nanoid", "perry-ffi", @@ -6078,7 +6078,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "bytes", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "lettre", "perry-ffi", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "notify", "perry-ffi", @@ -6134,7 +6134,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "printpdf", @@ -6142,7 +6142,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "sqlx", @@ -6151,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "perry-runtime", @@ -6160,7 +6160,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "governor", "perry-ffi", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "fast_image_resize", "image", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "lazy_static", "perry-ffi", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-ffi", @@ -6208,7 +6208,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "perry-runtime", @@ -6217,7 +6217,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-ffi", "uuid", @@ -6225,7 +6225,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "futures-util", "lazy_static", @@ -6238,7 +6238,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "brotli", "flate2", @@ -6248,7 +6248,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-api-manifest", @@ -6278,11 +6278,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1600" +version = "0.5.1601" [[package]] name = "perry-parser" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "perry-diagnostics", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perex", "regex", @@ -6303,7 +6303,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "ahash", "base64 0.22.1", @@ -6361,14 +6361,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6455,21 +6455,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "dirs", "perry-ffi", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "jni", @@ -6494,7 +6494,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "rand 0.10.2", "serde", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6527,7 +6527,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "block2", @@ -6544,7 +6544,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "block2", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1600" +version = "0.5.1601" [[package]] name = "perry-ui-test" @@ -6572,11 +6572,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1600" +version = "0.5.1601" [[package]] name = "perry-ui-tvos" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "block2", @@ -6593,7 +6593,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "block2", @@ -6610,7 +6610,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "block2", "libc", @@ -6624,7 +6624,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "libc", @@ -6643,7 +6643,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "base64 0.22.1", "libc", @@ -6656,7 +6656,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "anyhow", "base64 0.22.1", @@ -6671,7 +6671,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1600" +version = "0.5.1601" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 60451d9349..cf7d476b65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -335,7 +335,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1600" +version = "0.5.1601" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"