Skip to content

Commit c62314a

Browse files
Lewin671claude
andcommitted
Recognize an array index without formatting it back to a string
`array_index_property_key` decided whether a string key names an array index by parsing it and then formatting the parsed number into a **fresh `String`** to compare against the original. That is a heap allocation inside a predicate the element path asks constantly: `has_index`, the element read and the element write each call it, and so do the enumeration filters, the descriptor paths, and the proxy and typed-array key checks. It showed up as exactly that -- 39 allocator samples on `string-tagcloud`, whose profile is 40% malloc and free. The canonical spelling is what the digits already say: no sign, no leading zero unless the key is `"0"`, at most ten digits, and a value below `u32::MAX`, which is the length bound rather than an index. The accumulation is checked because ten digits still overflow above `4294967295` -- the test added here caught precisely that, an unchecked first draft accepting `"4294967296"` as index 0. 39-case corpus 0.9971: `math-spectral-norm` 0.946, `date-format-tofte` 0.956, `date-format-xparb` 0.958, `string-tagcloud` 0.960, `string-unpack-code` 0.972. `object.rs` reached the file-size guard, so its unit tests move to `value/object/tests.rs`, next to the two other test files the crate already keeps beside their subject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 55fa0d4 commit c62314a

2 files changed

Lines changed: 241 additions & 186 deletions

File tree

crates/qjs-runtime/src/value/object.rs

Lines changed: 30 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,197 +1787,41 @@ fn is_internal_property_key(key: &str) -> bool {
17871787
key.starts_with('\0')
17881788
}
17891789

1790+
/// Parses an array index in its canonical decimal spelling.
1791+
///
1792+
/// This is a predicate on the element path -- every string-keyed array access
1793+
/// asks it, and `has_index`, the element read, and the element write each ask
1794+
/// it again -- and it used to answer by formatting the parsed number back into
1795+
/// a fresh `String` to compare against the key. That is a heap allocation per
1796+
/// array access, and it showed up as such: 39 allocator samples on
1797+
/// `string-tagcloud` alone.
1798+
///
1799+
/// The canonical form is exactly what the digits already say: no sign, no
1800+
/// leading zero unless the key is `"0"`, and a value below `u32::MAX` (the
1801+
/// array length itself is not an index).
17901802
fn array_index_property_key(key: &str) -> Option<u32> {
1791-
key.parse::<u32>()
1792-
.ok()
1793-
.filter(|index| *index < u32::MAX && index.to_string() == key)
1803+
let bytes = key.as_bytes();
1804+
// `u32::MAX` has ten digits, so anything longer is out of range; a leading
1805+
// zero is a distinct string property rather than an index.
1806+
if bytes.is_empty() || bytes.len() > 10 || (bytes[0] == b'0' && bytes.len() > 1) {
1807+
return None;
1808+
}
1809+
let mut index: u32 = 0;
1810+
for byte in bytes {
1811+
let digit = byte.wrapping_sub(b'0');
1812+
if digit > 9 {
1813+
return None;
1814+
}
1815+
// Ten digits still overflow above `4294967295`, so the accumulation is
1816+
// checked rather than merely bounded by the length.
1817+
index = index.checked_mul(10)?.checked_add(u32::from(digit))?;
1818+
}
1819+
(index < u32::MAX).then_some(index)
17941820
}
17951821

17961822
fn is_array_index_key(key: &str) -> bool {
17971823
array_index_property_key(key).is_some()
17981824
}
17991825

18001826
#[cfg(test)]
1801-
mod tests {
1802-
use std::{collections::HashMap, mem, rc::Rc};
1803-
1804-
use super::{ObjectData, ObjectLiteralShape, ObjectRef, OwnDataPropertyWrite, PropertyStorage};
1805-
use crate::{Property, Value};
1806-
1807-
#[test]
1808-
fn cloned_object_is_a_pointer_sized_shared_handle() {
1809-
let object = ObjectRef::new(HashMap::new());
1810-
let cloned = object.clone();
1811-
1812-
assert!(Rc::ptr_eq(&object.0, &cloned.0));
1813-
assert!(object.ptr_eq(&cloned));
1814-
assert_eq!(
1815-
mem::size_of::<ObjectRef>(),
1816-
mem::size_of::<Rc<ObjectData>>()
1817-
);
1818-
}
1819-
1820-
#[test]
1821-
fn ordinary_object_keeps_cold_state_unallocated() {
1822-
let object = ObjectRef::new(HashMap::from([
1823-
("a".to_owned(), Value::Number(1.0)),
1824-
("b".to_owned(), Value::Number(2.0)),
1825-
]));
1826-
1827-
assert_eq!(object.get("a"), Some(Value::Number(1.0)));
1828-
assert!(object.own_property_symbols().is_empty());
1829-
assert!(object.to_string_tag().is_none());
1830-
assert!(object.0.cold.get().is_none());
1831-
// Boxing the cold `Dynamic` property-storage payload (HashMap + Vec)
1832-
// keeps this at 104 bytes instead of the 136 it cost when that
1833-
// payload sized the whole `PropertyStorage` enum for every object.
1834-
assert!(mem::size_of::<ObjectData>() <= 112);
1835-
}
1836-
1837-
#[test]
1838-
fn ordinary_small_object_promotes_only_after_the_compact_limit() {
1839-
let object = ObjectRef::new(HashMap::new());
1840-
1841-
for index in 0..PropertyStorage::SMALL_LIMIT {
1842-
object.set(format!("field{index}"), Value::Number(index as f64));
1843-
}
1844-
assert!(matches!(
1845-
&*object.0.properties.borrow(),
1846-
PropertyStorage::Small { entries } if entries.len() == PropertyStorage::SMALL_LIMIT
1847-
));
1848-
1849-
let dynamic_index = PropertyStorage::SMALL_LIMIT;
1850-
object.set(
1851-
format!("field{dynamic_index}"),
1852-
Value::Number(dynamic_index as f64),
1853-
);
1854-
assert!(matches!(
1855-
&*object.0.properties.borrow(),
1856-
PropertyStorage::Dynamic(dynamic)
1857-
if dynamic.properties.len() == PropertyStorage::SMALL_LIMIT + 1
1858-
&& dynamic.order.len() == PropertyStorage::SMALL_LIMIT + 1
1859-
));
1860-
assert_eq!(
1861-
object.own_property_names(),
1862-
(0..=PropertyStorage::SMALL_LIMIT)
1863-
.map(|index| format!("field{index}"))
1864-
.collect::<Vec<_>>()
1865-
);
1866-
}
1867-
1868-
#[test]
1869-
fn ordinary_object_retains_shared_static_property_key() {
1870-
let object = ObjectRef::new(HashMap::new());
1871-
let key: Rc<str> = Rc::from("field");
1872-
1873-
object.set_shared_key(Rc::clone(&key), Value::Number(1.0));
1874-
1875-
assert!(matches!(
1876-
&*object.0.properties.borrow(),
1877-
PropertyStorage::Small { entries }
1878-
if entries.len() == 1 && Rc::ptr_eq(&entries[0].0, &key)
1879-
));
1880-
assert_eq!(object.get("field"), Some(Value::Number(1.0)));
1881-
}
1882-
1883-
#[test]
1884-
fn small_object_removal_preserves_property_order() {
1885-
let object = ObjectRef::new(HashMap::new());
1886-
object.set("first".to_owned(), Value::Number(1.0));
1887-
object.set("second".to_owned(), Value::Number(2.0));
1888-
object.set("third".to_owned(), Value::Number(3.0));
1889-
1890-
assert!(object.delete_own_property("second"));
1891-
object.set("second".to_owned(), Value::Number(4.0));
1892-
1893-
assert_eq!(object.own_property_names(), ["first", "third", "second"]);
1894-
assert!(matches!(
1895-
&*object.0.properties.borrow(),
1896-
PropertyStorage::Small { entries } if entries.len() == 3
1897-
));
1898-
}
1899-
1900-
#[test]
1901-
fn small_object_enumerates_indices_before_strings() {
1902-
let object = ObjectRef::new(HashMap::new());
1903-
object.set("10".to_owned(), Value::Number(10.0));
1904-
object.set("label".to_owned(), Value::Number(0.0));
1905-
object.set("2".to_owned(), Value::Number(2.0));
1906-
1907-
assert_eq!(object.own_property_names(), ["2", "10", "label"]);
1908-
assert!(matches!(
1909-
&*object.0.properties.borrow(),
1910-
PropertyStorage::Small { entries } if entries.len() == 3
1911-
));
1912-
}
1913-
1914-
#[test]
1915-
fn existing_own_data_write_updates_or_rejects_without_slow_path() {
1916-
let object = ObjectRef::new(HashMap::from([("writable".to_owned(), Value::Number(1.0))]));
1917-
object.define_property(
1918-
"readonly".to_owned(),
1919-
Property::data(Value::Number(2.0), true, false, true),
1920-
);
1921-
1922-
assert!(matches!(
1923-
object.write_existing_own_data_property("writable", &Value::Number(3.0)),
1924-
OwnDataPropertyWrite::Written
1925-
));
1926-
assert_eq!(object.get("writable"), Some(Value::Number(3.0)));
1927-
assert!(matches!(
1928-
object.write_existing_own_data_property("readonly", &Value::Number(4.0)),
1929-
OwnDataPropertyWrite::ReadOnly
1930-
));
1931-
assert_eq!(object.get("readonly"), Some(Value::Number(2.0)));
1932-
assert!(matches!(
1933-
object.write_existing_own_data_property("missing", &Value::Number(5.0)),
1934-
OwnDataPropertyWrite::NeedsSlowPath
1935-
));
1936-
}
1937-
1938-
#[test]
1939-
fn literal_pair_keeps_inline_values_until_descriptor_mutation() {
1940-
let shape = ObjectLiteralShape::new(vec![Rc::from("a"), Rc::from("b")]);
1941-
let object =
1942-
ObjectRef::with_literal_pair(shape, [Value::Number(1.0), Value::Number(2.0)], None);
1943-
1944-
assert!(matches!(
1945-
&*object.0.properties.borrow(),
1946-
PropertyStorage::ShapedPair { .. }
1947-
));
1948-
assert!(matches!(
1949-
object.write_existing_own_data_property("a", &Value::Number(3.0)),
1950-
OwnDataPropertyWrite::Written
1951-
));
1952-
assert_eq!(object.get("a"), Some(Value::Number(3.0)));
1953-
assert!(matches!(
1954-
&*object.0.properties.borrow(),
1955-
PropertyStorage::ShapedPair { .. }
1956-
));
1957-
1958-
object.define_property(
1959-
"a".to_owned(),
1960-
Property::data(Value::Number(4.0), false, false, true),
1961-
);
1962-
assert!(matches!(
1963-
&*object.0.properties.borrow(),
1964-
PropertyStorage::Dynamic(_)
1965-
));
1966-
let descriptor = object.own_property("a").expect("defined property");
1967-
assert_eq!(descriptor.value, Value::Number(4.0));
1968-
assert!(!descriptor.enumerable);
1969-
assert!(!descriptor.writable);
1970-
}
1971-
1972-
#[test]
1973-
fn module_namespace_own_data_write_stays_on_slow_path() {
1974-
let object = ObjectRef::new(HashMap::from([("exported".to_owned(), Value::Number(1.0))]));
1975-
object.mark_module_namespace_exotic();
1976-
1977-
assert!(matches!(
1978-
object.write_existing_own_data_property("exported", &Value::Number(2.0)),
1979-
OwnDataPropertyWrite::NeedsSlowPath
1980-
));
1981-
assert_eq!(object.get("exported"), Some(Value::Number(1.0)));
1982-
}
1983-
}
1827+
mod tests;

0 commit comments

Comments
 (0)