diff --git a/CLAUDE.md b/CLAUDE.md index 5e5090efe4..4675051f29 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.1442 +**Current Version:** 0.5.1443 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 447517dae4..8721032f14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1442" +version = "0.5.1443" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1442" +version = "0.5.1443" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1442" +version = "0.5.1443" [[package]] name = "perry-ui-tvos" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1442" +version = "0.5.1443" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 5b5d5864dc..ffcbca7348 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1442" +version = "0.5.1443" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7755-registry-probe-latches.md b/changelog.d/7755-registry-probe-latches.md new file mode 100644 index 0000000000..078556d8d4 --- /dev/null +++ b/changelog.d/7755-registry-probe-latches.md @@ -0,0 +1,11 @@ +Make "is this value special?" free for programs that never use the feature. + +Symbolicated profiles of two unrelated realistic programs — an async service pipeline and a tree-walking interpreter — spent **13% of runtime** probing address-keyed side tables for typed arrays, `Buffer`s, `SharedArrayBuffer` backings and `Symbol`s that neither program ever allocates. These probes sit on generic paths (property get/set, element access, `typeof`, coercion, `JSON.stringify`, console formatting, and every `Date`/`Temporal` brand check via `is_offheap_sidetable_alloc`), so a program pays for every exotic kind it does not use, on every value it touches. Each miss cost at minimum a `_tlv_get_addr` — Darwin has no local-exec TLS — plus a `RefCell` borrow and a hash; `symbol::is_registered_symbol` and `buffer::is_uint8array_buffer` took a *process-global mutex* on every miss. + +New `perry-runtime/src/registry_latch.rs` generalises the #7474 Map/Set trick into `RegistryLatch`: a monotone process-global `AtomicBool` armed by the registration site and checked first by the probe, so an unused feature answers from one atomic load. It has no `disarm` by construction — monotonicity is the safety argument, since the only observation the design can get wrong is "idle while the table is non-empty", and "armed while empty" is merely slower. + +Latched: `typedarray::lookup_typed_array_kind`, `buffer::is_registered_buffer` (including the `shared_sab` fallback), `is_uint8array_buffer`, `is_array_buffer`, `is_shared_array_buffer`, `is_data_view`, `is_detached_buffer`, `is_secret_key`, `crypto_key_meta`, `asymmetric_key_meta`, `buffer_ab_alias`, `symbol::is_registered_symbol`, and the three `regex` pointer-table probes. `typedarray_props::typed_array_addr_from_value` and `typedarray::is_offheap_sidetable_alloc` are fixed transitively. + +The binding ordering rule, documented on the type: `arm()` runs **before** the registry mutation it advertises. Arming afterwards leaves a window in which the feature is live and reachable while the latch still reads idle, so a probe takes the fast path and denies a genuinely registered address — the hazard `js_buffer_register_external` already latches around. `shared_sab::alloc_shared_sab` was on the wrong side of that rule (it stored `SHARED_SAB_NONEMPTY` after its registry insert) and now arms first; it also arms the buffer latch, because a SAB backing reads as a registered buffer without ever entering `BUFFER_REGISTRY`. `Acquire`/`Release` is stronger than the runtime's current cross-thread hand-off routes require, chosen so the reasoning stays valid if a future path publishes a heap address without a mutex. + +`registry_latch_probes.rs` covers every feature in the case where an ordering bug would hide — take the probe's idle fast path first, *then* register, then require the probe to find it — for typed arrays, `Buffer`, `Uint8Array`, `ArrayBuffer`, `DataView`, detached buffers, `SharedArrayBuffer` (including one allocated on another thread), `Symbol`, `Map` and `Set`. The ordering rule is proven rather than asserted: `latch_semantics` models both orderings and *requires* arm-after-publish to produce an observable "live entry reads as absent" window, so the correct-order test cannot pass vacuously. diff --git a/crates/perry-runtime/src/buffer/detach.rs b/crates/perry-runtime/src/buffer/detach.rs index 58b343d107..38f2e63394 100644 --- a/crates/perry-runtime/src/buffer/detach.rs +++ b/crates/perry-runtime/src/buffer/detach.rs @@ -27,8 +27,17 @@ thread_local! { RefCell::new(new_ptr_hash_set()); } +/// Monotone "an ArrayBuffer has been detached in this process" latch — nothing +/// detached ⟹ nothing to find. See `crate::registry_latch`. +static EVER_DETACHED: crate::registry_latch::RegistryLatch = + crate::registry_latch::RegistryLatch::new(); + /// `ArrayBuffer.prototype.detached` — true after a successful transfer. +#[inline] pub fn is_detached_buffer(addr: usize) -> bool { + if EVER_DETACHED.is_idle() { + return false; + } DETACHED_BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) } @@ -51,6 +60,8 @@ pub fn detach_array_buffer(addr: usize) { (*buf).length = 0; (*buf).capacity = 0; } + // Arm before the insert — see `crate::registry_latch`. + EVER_DETACHED.arm(); DETACHED_BUFFER_REGISTRY.with(|r| { r.borrow_mut().insert(addr); }); diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index 0a0824690a..39b594affb 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -138,44 +138,104 @@ thread_local! { RefCell::new(new_ptr_hash_map()); } +use crate::registry_latch::RegistryLatch; + +/// Monotone "at least one `Buffer`-shaped allocation exists" latch. +/// +/// `is_registered_buffer` is one of the two hottest generic-path probes in the +/// runtime (measured 2.40% of an async service pipeline and 1.9% of a +/// tree-walking interpreter, neither of which allocates a `Buffer`): it is +/// reached from `typedarray::is_offheap_sidetable_alloc` — and therefore from +/// every `Date`/`Temporal` brand check — from `JSON.stringify` for every +/// pointer value serialized (#6009), from console formatting, from array +/// indexing and from ~200 other sites. Without the latch each of those pays a +/// `_tlv_get_addr`, a `RefCell` borrow and a hash probe, plus a call into +/// `shared_sab::is_shared_sab`. +/// +/// The latch covers the SAB fallback too, so the idle answer is a single atomic +/// load rather than one per registry — hence [`note_buffer_like_registered`], +/// which `shared_sab::alloc_shared_sab` calls before publishing a backing. +static BUFFER_LIKE_EVER_REGISTERED: RegistryLatch = RegistryLatch::new(); + +/// Arm the `is_registered_buffer` latch from outside this module. +/// +/// `shared_sab` publishes process-global backings that `is_registered_buffer` +/// reports as buffers without them ever entering `BUFFER_REGISTRY`, so it must +/// arm the same latch — and, per the [`crate::registry_latch`] rule, must do so +/// *before* the backing becomes reachable. +pub(crate) fn note_buffer_like_registered() { + BUFFER_LIKE_EVER_REGISTERED.arm(); +} + +/// Monotone latches for the remaining address-keyed buffer side tables. Each +/// probe below sits on a generic path (`util.types` predicates, `.buffer` / +/// `.byteLength` property reads, KeyObject dispatch, typed-array own-property +/// resolution) and each is a pure "is this value special?" question that a +/// program never using the feature should answer for free. +static ARRAY_BUFFER_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +static SHARED_ARRAY_BUFFER_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +static DATA_VIEW_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +static UINT8ARRAY_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +static SECRET_KEY_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +static CRYPTO_KEY_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +static ASYMMETRIC_KEY_EVER_MARKED: RegistryLatch = RegistryLatch::new(); +static BUFFER_AB_ALIAS_EVER_SET: RegistryLatch = RegistryLatch::new(); + pub fn mark_as_array_buffer(addr: usize) { + ARRAY_BUFFER_EVER_MARKED.arm(); ARRAY_BUFFER_REGISTRY.with(|r| { r.borrow_mut().insert(addr); }); } +#[inline] pub fn is_array_buffer(addr: usize) -> bool { + if ARRAY_BUFFER_EVER_MARKED.is_idle() { + return false; + } ARRAY_BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) } pub fn mark_as_shared_array_buffer(addr: usize) { + SHARED_ARRAY_BUFFER_EVER_MARKED.arm(); SHARED_ARRAY_BUFFER_REGISTRY.with(|r| { r.borrow_mut().insert(addr); }); } +#[inline] pub fn is_shared_array_buffer(addr: usize) -> bool { - if SHARED_ARRAY_BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) { + if SHARED_ARRAY_BUFFER_EVER_MARKED.is_armed() + && SHARED_ARRAY_BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) + { return true; } // #4913: a SAB backing is process-global. If this thread received it as a // module-level value (not a serialized `perry/thread` capture, which would // have re-registered it locally) the thread-local set misses, so fall back // to the process-global registry. Slow path only — thread-local hits first. + // (`is_shared_sab` carries its own `SHARED_SAB_NONEMPTY` latch, so the + // no-SAB process pays one more atomic load and no lock.) crate::shared_sab::is_shared_sab(addr) } +#[inline] pub fn is_any_array_buffer(addr: usize) -> bool { is_array_buffer(addr) || is_shared_array_buffer(addr) } pub fn mark_as_data_view(addr: usize) { + DATA_VIEW_EVER_MARKED.arm(); DATA_VIEW_REGISTRY.with(|r| { r.borrow_mut().insert(addr); }); } +#[inline] pub fn is_data_view(addr: usize) -> bool { + if DATA_VIEW_EVER_MARKED.is_idle() { + return false; + } DATA_VIEW_REGISTRY.with(|r| r.borrow().contains(&addr)) } @@ -202,6 +262,10 @@ pub fn register_buffer(ptr: *const BufferHeader) { // address, and without this the no-ops would carry over and the real packet // would serialize as all zeros (the MySQL server then times out reading it). super::own_props::clear_buffer_own_props(ptr as usize); + // Arm BEFORE the insert: an arm placed afterwards leaves a window in which + // this buffer is in the registry while `is_registered_buffer` still takes + // the idle fast path and denies it. See `crate::registry_latch`. + BUFFER_LIKE_EVER_REGISTERED.arm(); BUFFER_REGISTRY.with(|r| r.borrow_mut().insert(ptr as usize)); } @@ -220,7 +284,21 @@ pub(crate) fn is_small_buf_slab_addr(_addr: usize) -> bool { } /// Check if a pointer is a registered buffer (for instanceof Uint8Array) +#[inline] pub fn is_registered_buffer(addr: usize) -> bool { + // Nothing buffer-shaped has ever been registered anywhere in this process + // ⟹ nothing to find, in one atomic load. `register_buffer` (which + // `js_buffer_register_external` also routes through) and + // `shared_sab::alloc_shared_sab` both arm this latch before they publish. + if BUFFER_LIKE_EVER_REGISTERED.is_idle() { + return false; + } + is_registered_buffer_slow(addr) +} + +/// Out of line so the idle check inlines into its ~200 call sites. +#[inline(never)] +fn is_registered_buffer_slow(addr: usize) -> bool { if BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) { return true; } @@ -241,6 +319,7 @@ pub fn is_registered_buffer(addr: usize) -> bool { /// Mark this buffer as one that came from `new Uint8Array(...)` so it /// formats as `Uint8Array(N) [ ... ]` rather than ``. pub fn mark_as_uint8array(addr: usize) { + UINT8ARRAY_EVER_MARKED.arm(); UINT8ARRAY_FROM_CTOR.with(|r| { r.borrow_mut().insert(addr); }); @@ -267,12 +346,17 @@ pub extern "C" fn js_buffer_mark_as_uint8array_external(addr: usize) { } pub fn mark_as_secret_key(addr: usize) { + SECRET_KEY_EVER_MARKED.arm(); SECRET_KEY_REGISTRY.with(|r| { r.borrow_mut().insert(addr); }); } +#[inline] pub fn is_secret_key(addr: usize) -> bool { + if SECRET_KEY_EVER_MARKED.is_idle() { + return false; + } SECRET_KEY_REGISTRY.with(|r| r.borrow().contains(&addr)) } @@ -297,6 +381,7 @@ pub fn mark_as_crypto_key_with_flags( usages: u32, bit_length: u32, ) { + CRYPTO_KEY_EVER_MARKED.arm(); CRYPTO_KEY_META_REGISTRY.with(|r| { r.borrow_mut() .insert(addr, (algo, hash, kind, extractable, usages, bit_length)); @@ -333,6 +418,11 @@ pub extern "C" fn js_buffer_mark_as_crypto_key_external( } pub fn crypto_key_meta(addr: usize) -> Option { + // `js_buffer_mark_as_crypto_key_external` arms via + // `mark_as_crypto_key_with_flags` before touching either table. + if CRYPTO_KEY_EVER_MARKED.is_idle() { + return None; + } CRYPTO_KEY_META_REGISTRY .with(|r| r.borrow().get(&addr).copied()) .or_else(|| { @@ -376,16 +466,35 @@ fn default_crypto_key_usages(algo: u8, kind: u8) -> u32 { /// `kind`: 1 public, 2 private. `asym_type`: 1 rsa, 2 ec, 3 ed25519, 4 x25519. pub fn mark_as_asymmetric_key(addr: usize, kind: u8, asym_type: u8) { + ASYMMETRIC_KEY_EVER_MARKED.arm(); ASYMMETRIC_KEY_REGISTRY.with(|r| { r.borrow_mut().insert(addr, (kind, asym_type)); }); } +#[inline] pub fn asymmetric_key_meta(addr: usize) -> Option<(u8, u8)> { + if ASYMMETRIC_KEY_EVER_MARKED.is_idle() { + return None; + } ASYMMETRIC_KEY_REGISTRY.with(|r| r.borrow().get(&addr).copied()) } +#[inline] pub fn is_uint8array_buffer(addr: usize) -> bool { + // Reached from `typedarray_props::typed_array_owner_kind` for every untyped + // element access, so the idle case must not take the global mutex: before + // the latch this locked `external_uint8arrays()` on EVERY thread-local + // miss, i.e. on every non-Uint8Array value, in every process — the one + // probe in this family whose miss cost a lock rather than a hash. + if UINT8ARRAY_EVER_MARKED.is_idle() { + return false; + } + is_uint8array_buffer_slow(addr) +} + +#[inline(never)] +fn is_uint8array_buffer_slow(addr: usize) -> bool { UINT8ARRAY_FROM_CTOR.with(|r| r.borrow().contains(&addr)) || external_uint8arrays() .lock() @@ -397,6 +506,7 @@ pub fn is_uint8array_buffer(addr: usize) -> bool { /// `buf` itself. Used by copy paths (`Buffer.from(src)`) to propagate the /// source's ArrayBuffer identity onto the new buffer — see #1225. pub fn set_buffer_ab_alias(buf: usize, alias: usize) { + BUFFER_AB_ALIAS_EVER_SET.arm(); BUFFER_AB_ALIAS.with(|m| { m.borrow_mut().insert(buf, alias); }); @@ -405,7 +515,11 @@ pub fn set_buffer_ab_alias(buf: usize, alias: usize) { /// Look up the ArrayBuffer-identity alias for a Buffer. Returns `None` for /// buffers that haven't been involved in a copy chain (their `.buffer` just /// returns themselves, as before). +#[inline] pub fn buffer_ab_alias(buf: usize) -> Option { + if BUFFER_AB_ALIAS_EVER_SET.is_idle() { + return None; + } BUFFER_AB_ALIAS.with(|m| m.borrow().get(&buf).copied()) } diff --git a/crates/perry-runtime/src/buffer/mod.rs b/crates/perry-runtime/src/buffer/mod.rs index 0ffcf8572b..bf0aebefdb 100644 --- a/crates/perry-runtime/src/buffer/mod.rs +++ b/crates/perry-runtime/src/buffer/mod.rs @@ -37,6 +37,10 @@ pub use header::{BufferHeader, BUFFER_TYPE_ID, SMALL_BUF_THRESHOLD}; // ---- Re-exports: allocation / registry helpers ---- pub(crate) use header::is_small_buf_slab_addr; +// `shared_sab` publishes process-global backings that `is_registered_buffer` +// reports as buffers without them entering `BUFFER_REGISTRY`, so it arms the +// same monotone latch — before the backing becomes reachable. +pub(crate) use header::note_buffer_like_registered; pub use header::{ asymmetric_key_meta, buffer_ab_alias, buffer_alloc, buffer_backing_array_buffer, buffer_byte_offset, buffer_data, buffer_data_mut, crypto_key_meta, ensure_buffer_ab_alias, diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 6ba4daadf6..8dbbdcc979 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -139,6 +139,9 @@ pub mod pty; pub mod punycode; pub mod readline_helpers; pub mod regex; +pub mod registry_latch; +#[cfg(test)] +mod registry_latch_probes; pub mod safe_area; pub mod set; pub mod shared_sab; diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 202ecc2d33..0444a3070d 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -138,7 +138,26 @@ pub(crate) fn is_regex_pointer(ptr: *const u8) -> bool { if regex_header_has_magic(ptr as *const RegExpHeader) { return true; } - REGEX_POINTERS.with(|s| s.borrow().contains(&(ptr as usize))) + regex_pointers_contains(ptr as usize) +} + +/// Monotone "this process has ever constructed a `RegExp`" latch. +/// +/// The three `REGEX_POINTERS` probes all reach the thread-local table only +/// *after* the header-magic check misses — which is the common case, since they +/// are asked about ordinary objects on the generic property-dispatch path +/// (`object::exotic_expando::exotic_expando_kind`) and from `String.prototype` +/// dispatch. A program with no regex answers from one atomic load. +/// See `crate::registry_latch` for the ordering rule. +static REGEX_EVER_REGISTERED: crate::registry_latch::RegistryLatch = + crate::registry_latch::RegistryLatch::new(); + +#[inline] +fn regex_pointers_contains(addr: usize) -> bool { + if REGEX_EVER_REGISTERED.is_idle() { + return false; + } + REGEX_POINTERS.with(|s| s.borrow().contains(&addr)) } /// Bounds-checked read of `RegExpHeader.magic`. Confirms the preceding @@ -499,7 +518,7 @@ pub(crate) fn is_valid_regex_ptr(p: *const RegExpHeader) -> bool { if regex_header_has_magic(p) { return true; } - REGEX_POINTERS.with(|s| s.borrow().contains(&(p as usize))) + regex_pointers_contains(p as usize) } /// Public: is `addr` a RegExpHeader we allocated via `js_regexp_new`? @@ -512,7 +531,7 @@ pub fn is_registered_regex(addr: usize) -> bool { if regex_header_has_magic(addr as *const RegExpHeader) { return true; } - REGEX_POINTERS.with(|s| s.borrow().contains(&addr)) + regex_pointers_contains(addr) } /// Internal helper: Get string data from StringHeader @@ -867,6 +886,8 @@ pub extern "C" fn js_regexp_new( // Record the pointer so that js_string_split can detect // `s.split(regex)` without a dedicated runtime decl. + // Arm before the insert — see `crate::registry_latch`. + REGEX_EVER_REGISTERED.arm(); REGEX_POINTERS.with(|s| { s.borrow_mut().insert(ptr as usize); }); diff --git a/crates/perry-runtime/src/registry_latch.rs b/crates/perry-runtime/src/registry_latch.rs new file mode 100644 index 0000000000..5f8f6a2e6d --- /dev/null +++ b/crates/perry-runtime/src/registry_latch.rs @@ -0,0 +1,139 @@ +//! Monotone "has this feature ever been used?" latches for side-table probes. +//! +//! The runtime answers "is this value special?" for a large number of exotic +//! kinds — typed array, `Buffer`, `SharedArrayBuffer` backing, `Symbol`, +//! `DataView`, `ArrayBuffer`, `Map`, `Set` — by consulting an address-keyed +//! side table. Those probes sit on *generic* paths (property get/set, element +//! access, `typeof`, coercion, `JSON.stringify`, console formatting, GC header +//! reads), so a program pays for every kind it does not use, on every value it +//! touches. Each probe costs at minimum a thread-local resolution (on Darwin +//! that is a real call through `_tlv_get_addr` — there is no local-exec TLS) +//! plus a `RefCell` borrow and a hash, and for the process-global tables a +//! mutex acquisition. +//! +//! Symbolicated profiles of two unrelated realistic programs measured **13% of +//! total runtime** in exactly these probes, for features neither program used: +//! an async service pipeline spent 2.45% in `lookup_typed_array_kind`, 2.40% in +//! `is_registered_buffer`, 1.22% in `is_shared_sab` and 0.71% in +//! `is_registered_symbol` while allocating no typed array, no `Buffer`, no +//! `SharedArrayBuffer` and no `Symbol`; a tree-walking interpreter showed the +//! same two leaders independently. +//! +//! [`RegistryLatch`] removes that tax. It is a process-global `AtomicBool` that +//! starts `false` and is armed by the *registration* site. A probe checks it +//! first and answers "no" from a single atomic load when the feature has never +//! been used. #7474 established the pattern for `map`/`set`; this type +//! generalises it so the remaining probes get it without copy-paste. +//! +//! # Why monotone +//! +//! The latch has no `disarm`, deliberately — there is no counter to get wrong +//! and no ordering hazard between an unregister and a concurrent probe. Once +//! armed the process pays the ordinary slow path forever, which is merely +//! slower, never wrong. That asymmetry is the whole safety argument: the only +//! *incorrect* observation this design can produce is `idle` while a table is +//! non-empty. `armed` while every table is empty is free of consequence. +//! +//! # The ordering rule (binding) +//! +//! **`arm()` must be called BEFORE the registry mutation it advertises, in the +//! registering thread's program order.** Arming *after* the insert opens a +//! window in which the feature is live and reachable but the latch still reads +//! idle, so a concurrent probe takes the fast path and answers `false` for an +//! address that is genuinely registered. That is not hypothetical: the sibling +//! latch `buffer::header::EXTERNAL_BUFFERS_NONEMPTY` carries an inline comment +//! for precisely this reason, and `js_buffer_register_external` latches first. +//! +//! With the arm placed first, the argument for a reader on *another* thread is: +//! a thread can only probe an address it holds, and every route by which an +//! address reaches a different thread in this runtime passes through a +//! synchronising edge (the `SerializedValue` deep-copy queue and the +//! `PENDING_THREAD_RESULTS` drain are both mutex/channel mediated). The arm +//! precedes the registration, which precedes the hand-off, so the arm is in the +//! reader's happens-before past and the reader must observe it. The +//! thread-local tables (`BUFFER_REGISTRY`, `TYPED_ARRAY_REGISTRY`, +//! `UINT8ARRAY_FROM_CTOR`) need even less: only the arming thread can find +//! their entries at all, and a thread always observes its own prior store. +//! +//! `Acquire`/`Release` is therefore stronger than today's routes require — +//! `Relaxed` would be sound given the hand-off edges above. It costs one +//! instruction and removes the need to re-audit this file the next time someone +//! publishes a heap address through a lock-free path, so the stronger ordering +//! is what ships. + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// A one-way "this feature has been registered at least once" flag. +/// +/// See the module docs for the ordering rule: arm before you publish. +#[derive(Debug, Default)] +pub struct RegistryLatch { + armed: AtomicBool, +} + +impl RegistryLatch { + /// A latch that has never been armed. + pub const fn new() -> Self { + Self { + armed: AtomicBool::new(false), + } + } + + /// True while nothing has ever been registered, so an address-keyed probe + /// over the guarded table can answer "not found" without touching it. + /// + /// This is the hot side: one relaxed-cost atomic load in place of a + /// thread-local resolution plus a hash probe (plus, for the global tables, + /// a mutex acquisition). + #[inline(always)] + pub fn is_idle(&self) -> bool { + !self.armed.load(Ordering::Acquire) + } + + /// True once anything has ever been registered. Never goes back to false. + #[inline(always)] + pub fn is_armed(&self) -> bool { + self.armed.load(Ordering::Acquire) + } + + /// Publish "this feature is now in use". + /// + /// MUST run **before** the guarded table is mutated — see the module docs. + /// + /// The load-then-store keeps a hot registration loop from re-dirtying a + /// shared cache line on every allocation once the latch is already armed; + /// skipping the store is sound because some earlier `Release` store already + /// established the value, and the latch never travels back to `false`. + #[inline] + pub fn arm(&self) { + if !self.armed.load(Ordering::Relaxed) { + self.armed.store(true, Ordering::Release); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_idle_and_arms_once() { + let latch = RegistryLatch::new(); + assert!(latch.is_idle()); + assert!(!latch.is_armed()); + latch.arm(); + assert!(!latch.is_idle()); + assert!(latch.is_armed()); + // Monotone: re-arming is a no-op, and there is deliberately no way back. + latch.arm(); + assert!(latch.is_armed()); + } + + #[test] + fn arm_is_visible_to_another_thread() { + static LATCH: RegistryLatch = RegistryLatch::new(); + assert!(LATCH.is_idle()); + std::thread::spawn(|| LATCH.arm()).join().unwrap(); + assert!(LATCH.is_armed()); + } +} diff --git a/crates/perry-runtime/src/registry_latch_probes.rs b/crates/perry-runtime/src/registry_latch_probes.rs new file mode 100644 index 0000000000..3c450a6130 --- /dev/null +++ b/crates/perry-runtime/src/registry_latch_probes.rs @@ -0,0 +1,315 @@ +//! Correctness tests for the monotone side-table probe latches. +//! +//! The latches in [`crate::registry_latch`] make "is this value special?" free +//! for programs that never use the feature. Speed is the easy half; the hard +//! half is that a program which *does* use the feature must still work, and in +//! particular must still work when the feature is first used **after** the +//! probe's idle fast path has already been taken. Every test below therefore +//! takes the fast path first and only then registers. +//! +//! The `latch_semantics` tests at the bottom prove the ordering rule is +//! load-bearing rather than decorative: they model both orderings of +//! arm-vs-insert and show that only the wrong one can be observed as +//! "idle while the table already holds the entry". + +use crate::registry_latch::RegistryLatch; + +/// A heap-plausible address that is not registered in any side table, and whose +/// `addr - 8` word is readable — the probes that read a `GcHeader` (regex magic, +/// Date/Temporal brands) must be safe to call on arbitrary pointer-shaped +/// values, so the scratch address deliberately has readable bytes in front of +/// it rather than being a bare integer. +fn unregistered_scratch_addr() -> usize { + let boxed: Box<[u64; 16]> = Box::new([0; 16]); + let base = Box::into_raw(boxed) as usize; // leaked on purpose: process-lifetime + base + 64 +} + +/// Every probe must answer "no" for an address nothing ever registered. This is +/// the fast path when the latch is idle and the ordinary table miss when it is +/// not, so the assertion holds in both states and the test is order-independent. +#[test] +fn unregistered_address_misses_every_probe() { + let addr = unregistered_scratch_addr(); + + assert_eq!(crate::typedarray::lookup_typed_array_kind(addr), None); + assert!(!crate::buffer::is_registered_buffer(addr)); + assert!(!crate::buffer::is_uint8array_buffer(addr)); + assert!(!crate::buffer::is_array_buffer(addr)); + assert!(!crate::buffer::is_shared_array_buffer(addr)); + assert!(!crate::buffer::is_any_array_buffer(addr)); + assert!(!crate::buffer::is_data_view(addr)); + assert!(!crate::buffer::is_secret_key(addr)); + assert!(!crate::buffer::is_detached_buffer(addr)); + assert_eq!(crate::buffer::crypto_key_meta(addr), None); + assert_eq!(crate::buffer::asymmetric_key_meta(addr), None); + assert_eq!(crate::buffer::buffer_ab_alias(addr), None); + assert!(!crate::symbol::is_registered_symbol(addr)); + assert!(!crate::shared_sab::is_shared_sab(addr)); + assert!(!crate::regex::is_registered_regex(addr)); + assert!(!crate::map::is_registered_map(addr)); + assert!(!crate::set::is_registered_set(addr)); +} + +/// #7474-shape regression: constructing a typed array AFTER the idle fast path +/// has already answered "not a typed array" must still register. A latch armed +/// after the registry insert — or a stale negative left in the `PERRY_TA_KIND_CACHE` +/// by the idle path — would make this array invisible to every `instanceof`, +/// element-access and formatting path. +#[test] +fn typed_array_is_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + // 1. take the probe's fast path at least once. + assert_eq!(crate::typedarray::lookup_typed_array_kind(scratch), None); + + // 2. only now create the feature. + let ta = crate::typedarray::js_typed_array_new(crate::typedarray::KIND_FLOAT64 as i32, 4.0); + assert!(!ta.is_null(), "test premise: the typed array allocated"); + + // 3. the probe must see it. + assert_eq!( + crate::typedarray::lookup_typed_array_kind(ta as usize), + Some(crate::typedarray::KIND_FLOAT64), + "a typed array created after the idle fast path must still be registered" + ); + assert!( + crate::typedarray::typed_array_registry_ever_used(), + "registering a typed array must arm the latch" + ); + // The unrelated scratch address must NOT have become a typed array. + assert_eq!(crate::typedarray::lookup_typed_array_kind(scratch), None); +} + +#[test] +fn buffer_is_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + assert!(!crate::buffer::is_registered_buffer(scratch)); + + let buf = crate::buffer::buffer_alloc(32); + assert!(!buf.is_null(), "test premise: the buffer allocated"); + + assert!( + crate::buffer::is_registered_buffer(buf as usize), + "a Buffer created after the idle fast path must still be registered" + ); + assert!(!crate::buffer::is_registered_buffer(scratch)); +} + +#[test] +fn uint8array_mark_is_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + assert!(!crate::buffer::is_uint8array_buffer(scratch)); + + let buf = crate::buffer::buffer_alloc(8) as usize; + crate::buffer::mark_as_uint8array(buf); + + assert!( + crate::buffer::is_uint8array_buffer(buf), + "`new Uint8Array(...)` identity must survive the idle fast path" + ); + assert!(!crate::buffer::is_uint8array_buffer(scratch)); +} + +#[test] +fn array_buffer_and_data_view_marks_are_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + assert!(!crate::buffer::is_array_buffer(scratch)); + assert!(!crate::buffer::is_data_view(scratch)); + + let ab = crate::buffer::buffer_alloc(16) as usize; + crate::buffer::mark_as_array_buffer(ab); + let dv = crate::buffer::buffer_alloc(16) as usize; + crate::buffer::mark_as_data_view(dv); + + assert!(crate::buffer::is_array_buffer(ab)); + assert!(crate::buffer::is_any_array_buffer(ab)); + assert!(crate::buffer::is_data_view(dv)); + assert!(!crate::buffer::is_array_buffer(scratch)); + assert!(!crate::buffer::is_data_view(scratch)); +} + +/// A `SharedArrayBuffer` backing is process-global and enters neither +/// thread-local registry, so it is the one case where a probe must answer "yes" +/// for an address the *local* tables have never seen. It therefore has to arm +/// `is_registered_buffer`'s latch as well as its own — an omission here would +/// leave every SAB invisible to `Buffer`/`Uint8Array` dispatch. +#[test] +fn shared_array_buffer_backing_is_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + assert!(!crate::buffer::is_registered_buffer(scratch)); + assert!(!crate::buffer::is_shared_array_buffer(scratch)); + + let sab = crate::shared_sab::alloc_shared_sab(64) as usize; + + assert!(crate::shared_sab::is_shared_sab(sab)); + assert!( + crate::buffer::is_registered_buffer(sab), + "a SAB backing must read as a registered buffer even though it never \ + enters BUFFER_REGISTRY — `alloc_shared_sab` arms that latch too" + ); + assert!(crate::buffer::is_shared_array_buffer(sab)); + assert!(crate::buffer::is_any_array_buffer(sab)); + assert!(!crate::buffer::is_shared_array_buffer(scratch)); +} + +/// The cross-thread half of the SAB contract: a backing allocated on another +/// agent must be recognised here. The latch is process-global precisely so this +/// keeps working — a thread-local latch would let the receiving thread take its +/// own idle fast path and deny an address that is genuinely shared. +#[test] +fn shared_array_buffer_allocated_on_another_thread_is_found_here() { + let sab = std::thread::spawn(|| crate::shared_sab::alloc_shared_sab(32) as usize) + .join() + .expect("SAB allocation thread"); + + assert!(crate::shared_sab::is_shared_sab(sab)); + assert!(crate::buffer::is_registered_buffer(sab)); + assert!(crate::buffer::is_shared_array_buffer(sab)); +} + +#[test] +fn symbol_is_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + assert!(!crate::symbol::is_registered_symbol(scratch)); + + let sym = unsafe { crate::symbol::alloc_symbol(std::ptr::null_mut(), false) } as usize; + assert!(sym != 0, "test premise: the symbol allocated"); + + assert!( + crate::symbol::is_registered_symbol(sym), + "a Symbol created after the idle fast path must still be registered" + ); + assert!(!crate::symbol::is_registered_symbol(scratch)); +} + +/// Map and Set already carried the #7474 latch; the contract is asserted here +/// alongside the rest so the whole family is covered by one test module. +#[test] +fn map_and_set_are_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + assert!(!crate::map::is_registered_map(scratch)); + assert!(!crate::set::is_registered_set(scratch)); + + let map = crate::map::js_map_alloc(4) as usize; + let set = crate::set::js_set_alloc(4) as usize; + + assert!(crate::map::is_registered_map(map)); + assert!(crate::set::is_registered_set(set)); + assert!(!crate::map::is_registered_map(scratch)); + assert!(!crate::set::is_registered_set(scratch)); +} + +#[test] +fn detached_buffer_mark_is_found_after_the_idle_fast_path_ran() { + let scratch = unregistered_scratch_addr(); + assert!(!crate::buffer::is_detached_buffer(scratch)); + + let ab = crate::buffer::buffer_alloc(16) as usize; + crate::buffer::mark_as_array_buffer(ab); + assert!(!crate::buffer::is_detached_buffer(ab)); + crate::buffer::detach_array_buffer(ab); + + assert!( + crate::buffer::is_detached_buffer(ab), + "`ArrayBuffer.prototype.detached` must survive the idle fast path" + ); + assert!(!crate::buffer::is_detached_buffer(scratch)); +} + +/// The ordering rule itself, modelled on a private latch + table pair so both +/// orderings can be run. This is the "prove the gate can fail" half: if +/// arm-after-insert were harmless the wrong-order case would be indistinguishable +/// from the right one, and none of the comments in `registry_latch.rs` would be +/// worth writing. +mod latch_semantics { + use super::*; + use std::cell::RefCell; + + thread_local! { + static TABLE: RefCell> = const { RefCell::new(Vec::new()) }; + } + + fn table_contains(addr: usize) -> bool { + TABLE.with(|t| t.borrow().contains(&addr)) + } + + fn probe(latch: &RegistryLatch, addr: usize) -> bool { + if latch.is_idle() { + return false; + } + table_contains(addr) + } + + /// The rule: arm, then publish. + fn register_correctly(latch: &RegistryLatch, addr: usize, observe: &mut dyn FnMut()) { + latch.arm(); + observe(); + TABLE.with(|t| t.borrow_mut().push(addr)); + observe(); + } + + /// The bug the rule exists to prevent: publish, then arm. + fn register_wrongly(latch: &RegistryLatch, addr: usize, observe: &mut dyn FnMut()) { + TABLE.with(|t| t.borrow_mut().push(addr)); + observe(); + latch.arm(); + observe(); + } + + #[test] + fn arm_before_publish_is_never_observably_inconsistent() { + let latch = RegistryLatch::new(); + let addr = 0xBEEF_0000usize; + let mut inconsistent = false; + { + let mut observe = || { + // The probe must never deny an entry the table already holds. + if table_contains(addr) && !probe(&latch, addr) { + inconsistent = true; + } + }; + register_correctly(&latch, addr, &mut observe); + } + assert!( + !inconsistent, + "arm-before-publish must have no window in which the table holds \ + the entry and the probe still answers `false`" + ); + assert!(probe(&latch, addr)); + TABLE.with(|t| t.borrow_mut().clear()); + } + + #[test] + fn arm_after_publish_is_observably_inconsistent() { + let latch = RegistryLatch::new(); + let addr = 0xFEED_0000usize; + let mut inconsistent = false; + { + let mut observe = || { + if table_contains(addr) && !probe(&latch, addr) { + inconsistent = true; + } + }; + register_wrongly(&latch, addr, &mut observe); + } + assert!( + inconsistent, + "sabotage check: publishing before arming MUST produce a window in \ + which a live entry reads as absent — if this stops failing, the \ + ordering rule has stopped being load-bearing and the check above \ + is proving nothing" + ); + TABLE.with(|t| t.borrow_mut().clear()); + } + + #[test] + fn latch_never_goes_back_to_idle() { + let latch = RegistryLatch::new(); + assert!(latch.is_idle()); + latch.arm(); + for _ in 0..4 { + latch.arm(); + assert!(!latch.is_idle()); + } + } +} diff --git a/crates/perry-runtime/src/shared_sab.rs b/crates/perry-runtime/src/shared_sab.rs index f918067984..4421c7f359 100644 --- a/crates/perry-runtime/src/shared_sab.rs +++ b/crates/perry-runtime/src/shared_sab.rs @@ -71,13 +71,19 @@ pub fn alloc_shared_sab(size: u32) -> *mut BufferHeader { (*buf).length = size; (*buf).capacity = size; } + // Latch BEFORE the insert, not after. `buffer::is_registered_buffer` and + // `buffer::is_shared_array_buffer` both report a SAB backing as a buffer + // without it ever entering their thread-local registries, so both latches + // must be armed before this address can be found — an arm placed after the + // insert leaves a window in which the entry is live and a probe still takes + // the idle fast path. (This is the ordering `js_buffer_register_external` + // already documents; see also `crate::registry_latch`.) + SHARED_SAB_NONEMPTY.store(true, Ordering::Release); + crate::buffer::note_buffer_like_registered(); registry() .lock() .unwrap_or_else(|e| e.into_inner()) .insert(buf as usize); - // Release-store AFTER the insert is visible, so a thread that observes the - // latch also observes the entry it was latched for. - SHARED_SAB_NONEMPTY.store(true, Ordering::Release); buf } @@ -125,11 +131,15 @@ pub(crate) fn snapshot_shared_sabs() -> Option> { /// registry is process-global and never cleared. #[cfg(test)] pub(crate) fn test_seed_shared_sab(addr: usize) { + // Same arm-before-publish ordering as `alloc_shared_sab`, so a seeded + // fixture exercises the real fast/slow-path split rather than a state the + // production path never produces. + SHARED_SAB_NONEMPTY.store(true, Ordering::Release); + crate::buffer::note_buffer_like_registered(); registry() .lock() .unwrap_or_else(|e| e.into_inner()) .insert(addr); - SHARED_SAB_NONEMPTY.store(true, Ordering::Release); } #[cfg(test)] diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 9855e0b0d5..0ab8d4248d 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -329,7 +329,20 @@ pub fn is_well_known_symbol(ptr: usize) -> bool { false } +/// Monotone "this process has ever created a `Symbol`" latch. +/// +/// `is_registered_symbol` is asked about ordinary pointer-shaped values on the +/// property/method dispatch paths, and until this latch existed it took a +/// *process-global* `Mutex` to answer — the most expensive miss in the type-probe +/// family, and 0.71% of an async-pipeline program that never mentions `Symbol`. +/// See `crate::registry_latch` for the ordering rule. +static SYMBOL_EVER_REGISTERED: crate::registry_latch::RegistryLatch = + crate::registry_latch::RegistryLatch::new(); + pub(crate) fn register_symbol_pointer(ptr: usize) { + // Arm before taking the lock, so the entry is never reachable while the + // latch still reads idle. + SYMBOL_EVER_REGISTERED.arm(); let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_POINTERS); if guard.is_none() { *guard = Some(HashSet::new()); @@ -373,7 +386,18 @@ pub fn intl_legacy_constructed_symbol() -> f64 { /// O(1) check whether a raw pointer (already untagged) is a known Symbol. /// Safe to call on any pointer-shaped value — no dereference is performed. +#[inline] pub fn is_registered_symbol(ptr: usize) -> bool { + // No symbol has ever been allocated ⟹ nothing to find, and in particular no + // reason to take the process-global registry mutex. + if SYMBOL_EVER_REGISTERED.is_idle() { + return false; + } + is_registered_symbol_slow(ptr) +} + +#[inline(never)] +fn is_registered_symbol_slow(ptr: usize) -> bool { if ptr < 0x10000 { return false; } diff --git a/crates/perry-runtime/src/typedarray/mod.rs b/crates/perry-runtime/src/typedarray/mod.rs index 9283f8adf5..8c6ec35bda 100644 --- a/crates/perry-runtime/src/typedarray/mod.rs +++ b/crates/perry-runtime/src/typedarray/mod.rs @@ -300,7 +300,24 @@ fn ta_kind_cache_get(addr: usize) -> Option> { } } +/// Monotone "this process has created at least one typed array" latch. +/// +/// `lookup_typed_array_kind` is one of the two hottest generic-path probes in +/// the runtime (measured 2.45% of an async-pipeline program and 3.4% of a +/// tree-walking interpreter, neither of which constructs a typed array). Even +/// with the #5525 direct-mapped cache in front of it, a program with no typed +/// arrays pays a cache probe, a `_tlv_get_addr`, a `RefCell` borrow, a hash and +/// a negative-entry write-back for every untyped element access and every +/// generic "is this special?" question. The latch collapses all of that to one +/// atomic load. See `crate::registry_latch` for the ordering rule. +static TYPED_ARRAY_EVER_REGISTERED: crate::registry_latch::RegistryLatch = + crate::registry_latch::RegistryLatch::new(); + pub fn register_typed_array(ptr: *const TypedArrayHeader, kind: u8) { + // Arm BEFORE either table becomes readable — an arm placed after the stores + // would leave a window in which `lookup_typed_array_kind` answers `None` for + // this very array. See `crate::registry_latch`. + TYPED_ARRAY_EVER_REGISTERED.arm(); // Keep the cache authoritative: overwrite any colliding/stale slot so a // freed-then-reused address never reads back its previous kind. ta_kind_cache_store(ptr as usize, kind); @@ -309,6 +326,12 @@ pub fn register_typed_array(ptr: *const TypedArrayHeader, kind: u8) { }); } +/// Test hook: has any typed array ever been registered in this process? +#[cfg(test)] +pub(crate) fn typed_array_registry_ever_used() -> bool { + TYPED_ARRAY_EVER_REGISTERED.is_armed() +} + pub fn unregister_typed_array(ptr: *const TypedArrayHeader) { let owner = ptr as usize; ta_kind_cache_invalidate(owner); @@ -325,7 +348,29 @@ pub fn unregister_typed_array(ptr: *const TypedArrayHeader) { /// Returns Some(kind) if the (already-stripped) address is a registered /// typed array, else None. +#[inline] pub fn lookup_typed_array_kind(addr: usize) -> Option { + // Nothing has ever been registered ⟹ nothing to find. Checked ahead of the + // #5525 cache because it is the only arm that costs neither a cache-slot + // load nor a negative-entry write-back: a program with no typed arrays runs + // every untyped `arr[i]` through here, and every one of those would + // otherwise miss the direct-mapped cache (cold address), resolve the + // thread-local registry and then dirty a shared cache line recording the + // miss. `register_typed_array` arms the latch before it populates either + // table, so an armed==false answer can never be wrong. + if TYPED_ARRAY_EVER_REGISTERED.is_idle() { + return None; + } + lookup_registered_typed_array_kind(addr) +} + +/// The real lookup, out of line so the idle check above inlines into its ~200 +/// call sites. Measured: with the check behind a call, the surviving self-time +/// of this function was the CALL, not the work — 2.3% of an async-pipeline +/// program and 1.8-2.3% of a tree-walking interpreter, in programs that create +/// no typed array at all. +#[inline(never)] +fn lookup_registered_typed_array_kind(addr: usize) -> Option { // #5525 fast path: the process-global cache resolves the hot, // repeated-same-address lookups without touching the thread-local // registry. A miss (cold address or direct-mapped eviction) falls back to diff --git a/crates/perry-runtime/src/typedarray_props.rs b/crates/perry-runtime/src/typedarray_props.rs index d15f041761..2e409f141f 100644 --- a/crates/perry-runtime/src/typedarray_props.rs +++ b/crates/perry-runtime/src/typedarray_props.rs @@ -31,6 +31,7 @@ enum TypedArrayOwnerKind { Uint8ArrayBuffer, } +#[inline] fn typed_array_owner_kind(owner: usize) -> Option { if lookup_typed_array_kind(owner).is_some() { Some(TypedArrayOwnerKind::TypedArray)