From 2b1c2b5b15792820517370d801b85698f7206405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 8 Sep 2026 19:23:25 +0200 Subject: [PATCH 1/7] feat(ffi): qualify native registration lifetimes Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- .../9990-native-registration-leases.md | 7 + crates/perry-ext-events/src/lib.rs | 82 +-- crates/perry-ext-events/src/registry.rs | 139 +++++ crates/perry-ext-net/src/handle_ids.rs | 33 +- crates/perry-ffi/src/handle.rs | 513 +++++------------- .../src/handle_registration_tests.rs | 105 ++++ crates/perry-ffi/src/lib.rs | 19 +- crates/perry-ffi/src/native_registration.rs | 458 ++++++++++++++++ .../src/native_registration/tests.rs | 451 +++++++++++++++ crates/perry-stdlib/src/common/handle.rs | 115 ++-- .../src/common/handle_registration_tests.rs | 48 ++ scripts/gc_runtime_root_holders.json | 6 + scripts/native_registration_sabotage.py | 442 +++++++++++++++ 13 files changed, 1938 insertions(+), 480 deletions(-) create mode 100644 changelog.d/9990-native-registration-leases.md create mode 100644 crates/perry-ext-events/src/registry.rs create mode 100644 crates/perry-ffi/src/handle_registration_tests.rs create mode 100644 crates/perry-ffi/src/native_registration.rs create mode 100644 crates/perry-ffi/src/native_registration/tests.rs create mode 100644 crates/perry-stdlib/src/common/handle_registration_tests.rs create mode 100644 scripts/native_registration_sabotage.py diff --git a/changelog.d/9990-native-registration-leases.md b/changelog.d/9990-native-registration-leases.md new file mode 100644 index 0000000000..40f42d45c6 --- /dev/null +++ b/changelog.d/9990-native-registration-leases.md @@ -0,0 +1,7 @@ +Add native registry-instance identities and monotonically issued registration +serials while preserving the numeric provider ABI. Coordinate wrapper and +operation leases with pending insertion, retirement, both quarantine tiers, +and bounded id reuse across FFI, Common, and External Events registries. Net +reservations retain their private registry domain while sharing the FFI id pool. +Explicit Common ids reject occupied or retained slots. JavaScript publication +and receiver representation remain unchanged in this preparatory phase. diff --git a/crates/perry-ext-events/src/lib.rs b/crates/perry-ext-events/src/lib.rs index e29413be7b..a78b808863 100644 --- a/crates/perry-ext-events/src/lib.rs +++ b/crates/perry-ext-events/src/lib.rs @@ -28,7 +28,7 @@ use perry_ffi::{ }; use std::collections::{HashMap, HashSet}; use std::ffi::c_void; -use std::sync::{Mutex, MutexGuard, Once, OnceLock}; +use std::sync::Once; mod error_monitor; use error_monitor::dispatch_error_monitor; @@ -415,82 +415,24 @@ impl EventEmitterHandle { } } -type EventEmitterRegistry = Vec>>; +mod registry; +#[cfg(test)] +use registry::drop_event_emitter_handle; +pub use registry::{ + acquire_event_emitter_registration, drain_quarantined_event_emitter_handles, + event_emitter_registration, event_emitter_registry_domain, +}; +use registry::{ + get_event_emitter_mut, is_local_event_emitter_handle, lock_event_emitters, + register_event_emitter_handle, +}; -static EVENT_EMITTERS: OnceLock> = OnceLock::new(); static EVENTS_RUNTIME_HOOKS_REGISTERED: Once = Once::new(); thread_local! { static EVENTS_GC_REGISTERED: std::cell::Cell = const { std::cell::Cell::new(false) }; } -fn event_emitters() -> &'static Mutex { - EVENT_EMITTERS.get_or_init(|| Mutex::new(Vec::new())) -} - -fn lock_event_emitters() -> MutexGuard<'static, EventEmitterRegistry> { - event_emitters() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -fn handle_index(handle: Handle) -> Option { - if !(EVENT_EMITTER_HANDLE_ID_START..EVENT_EMITTER_HANDLE_ID_END).contains(&handle) { - return None; - } - Some((handle - EVENT_EMITTER_HANDLE_ID_START) as usize) -} - -fn register_event_emitter_handle(value: EventEmitterHandle) -> Handle { - let mut registry = lock_event_emitters(); - if let Some((idx, slot)) = registry - .iter_mut() - .enumerate() - .find(|(_, slot)| slot.is_none()) - { - *slot = Some(Box::new(value)); - return EVENT_EMITTER_HANDLE_ID_START + idx as Handle; - } - let handle = EVENT_EMITTER_HANDLE_ID_START + registry.len() as Handle; - if handle >= EVENT_EMITTER_HANDLE_ID_END { - panic!("perry-ext-events handle id range exhausted"); - } - registry.push(Some(Box::new(value))); - handle -} - -fn event_emitter_ptr(handle: Handle) -> Option<*mut EventEmitterHandle> { - let idx = handle_index(handle)?; - let mut registry = lock_event_emitters(); - let slot = registry.get_mut(idx)?.as_mut()?; - Some(&mut **slot as *mut EventEmitterHandle) -} - -fn get_event_emitter_mut(handle: Handle) -> Option<&'static mut EventEmitterHandle> { - let ptr = event_emitter_ptr(handle)?; - Some(unsafe { &mut *ptr }) -} - -fn is_local_event_emitter_handle(handle: Handle) -> bool { - let Some(idx) = handle_index(handle) else { - return false; - }; - let registry = lock_event_emitters(); - registry.get(idx).is_some_and(|slot| slot.is_some()) -} - -#[cfg(test)] -fn drop_event_emitter_handle(handle: Handle) -> bool { - let Some(idx) = handle_index(handle) else { - return false; - }; - let mut registry = lock_event_emitters(); - let Some(slot) = registry.get_mut(idx) else { - return false; - }; - slot.take().is_some() -} - unsafe extern "C" fn event_emitter_handle_probe(handle: i64) -> bool { is_local_event_emitter_handle(handle) } diff --git a/crates/perry-ext-events/src/registry.rs b/crates/perry-ext-events/src/registry.rs new file mode 100644 index 0000000000..6e37831e90 --- /dev/null +++ b/crates/perry-ext-events/src/registry.rs @@ -0,0 +1,139 @@ +//! External Events owns a domain independently of FFI's numeric allocator. +//! Registry-state and payload-vector locks are never held together. + +use super::{ + EventEmitterHandle, Handle, EVENT_EMITTER_HANDLE_ID_END, EVENT_EMITTER_HANDLE_ID_START, +}; +#[cfg(test)] +use perry_ffi::NativeQuarantine; +use perry_ffi::{ + NativeLeaseKind, NativeRegistrationIdentity, NativeRegistrationKind, NativeRegistrationLease, + NativeRegistrationRegistry, NativeRegistryDomain, +}; +use std::sync::{LazyLock, Mutex, MutexGuard}; + +type EventEmitterRegistry = Vec>>; +static EVENT_EMITTERS: Mutex = Mutex::new(Vec::new()); +static REGISTRATIONS: LazyLock = LazyLock::new(|| { + NativeRegistrationRegistry::new( + EVENT_EMITTER_HANDLE_ID_START, + EVENT_EMITTER_HANDLE_ID_END, + 32 * 1024, + ) +}); + +pub fn event_emitter_registry_domain() -> NativeRegistryDomain { + REGISTRATIONS.domain() +} +pub fn event_emitter_registration(id: Handle) -> Option { + REGISTRATIONS.identity(id) +} +pub fn acquire_event_emitter_registration( + identity: NativeRegistrationIdentity, + kind: NativeLeaseKind, +) -> Option { + REGISTRATIONS.acquire(identity, kind) +} + +/// Native preparation API; production has no payload retirement path yet. +pub fn drain_quarantined_event_emitter_handles() -> usize { + REGISTRATIONS.drain(std::time::Instant::now()) +} + +pub(super) fn lock_event_emitters() -> MutexGuard<'static, EventEmitterRegistry> { + EVENT_EMITTERS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn handle_index(handle: Handle) -> Option { + if !(EVENT_EMITTER_HANDLE_ID_START..EVENT_EMITTER_HANDLE_ID_END).contains(&handle) { + return None; + } + Some((handle - EVENT_EMITTER_HANDLE_ID_START) as usize) +} + +pub(super) fn register_event_emitter_handle(value: EventEmitterHandle) -> Handle { + let identity = REGISTRATIONS + .begin_registration(NativeRegistrationKind::Payload) + .expect("perry-ext-events handle registration exhausted"); + let handle = identity.numeric_id(); + let idx = handle_index(handle).expect("allocated EventEmitter id must be in range"); + { + let mut registry = lock_event_emitters(); + if idx >= registry.len() { + registry.resize_with(idx + 1, || None); + } + assert!( + registry[idx].is_none(), + "pending EventEmitter id must have an empty slot" + ); + registry[idx] = Some(Box::new(value)); + } + assert!(REGISTRATIONS.publish(identity)); + handle +} + +pub(super) fn get_event_emitter_mut(handle: Handle) -> Option<&'static mut EventEmitterHandle> { + let idx = handle_index(handle)?; + let ptr = { + let mut registry = lock_event_emitters(); + &mut **registry.get_mut(idx)?.as_mut()? as *mut EventEmitterHandle + }; + // The existing provider contract orders payload removal after these borrows. + Some(unsafe { &mut *ptr }) +} + +pub(super) fn is_local_event_emitter_handle(handle: Handle) -> bool { + let Some(idx) = handle_index(handle) else { + return false; + }; + lock_event_emitters() + .get(idx) + .is_some_and(|slot| slot.is_some()) +} + +#[cfg(test)] +pub(super) fn drop_event_emitter_handle(handle: Handle) -> bool { + let Some(identity) = REGISTRATIONS.begin_retirement(handle, NativeRegistrationKind::Payload) + else { + return false; + }; + let removed = { + let mut registry = lock_event_emitters(); + registry + .get_mut(handle_index(handle).unwrap()) + .and_then(Option::take) + }; + assert!(REGISTRATIONS.finish_retirement(identity, NativeQuarantine::NextDrain)); + removed.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retained_event_emitter_registration_blocks_empty_slot_reuse() { + let id = register_event_emitter_handle(EventEmitterHandle::new()); + let identity = event_emitter_registration(id).unwrap(); + let lease = acquire_event_emitter_registration(identity, NativeLeaseKind::Wrapper).unwrap(); + assert!(drop_event_emitter_handle(id)); + drain_quarantined_event_emitter_handles(); + assert_eq!( + REGISTRATIONS.begin_registration_with_id(id, NativeRegistrationKind::Payload), + Err(perry_ffi::NativeRegistrationError::Occupied) + ); + let other = register_event_emitter_handle(EventEmitterHandle::new()); + assert_ne!( + other, id, + "retained Events registration must block empty-slot selection" + ); + assert_ne!( + event_emitter_registry_domain(), + perry_ffi::handle_registry_domain() + ); + drop(lease); + assert!(drop_event_emitter_handle(other)); + } +} diff --git a/crates/perry-ext-net/src/handle_ids.rs b/crates/perry-ext-net/src/handle_ids.rs index 05a3115b83..850467a214 100644 --- a/crates/perry-ext-net/src/handle_ids.rs +++ b/crates/perry-ext-net/src/handle_ids.rs @@ -30,7 +30,17 @@ /// the `0` sentinel through [`next_id_or_throw`]; background callers must guard /// it explicitly (never register an object under `0`). pub(crate) fn next_id() -> i64 { - perry_ffi::reserve_handle_id() + perry_ffi::reserve_handle_id_in_domain(net_registry_domain()) +} + +/// One authoritative domain for net's private payload maps, sharing only the +/// numeric allocation pool with FFI's ordinary payload registry. +pub(crate) fn net_registry_domain() -> perry_ffi::NativeRegistryDomain { + static DOMAIN: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + perry_ffi::NativeRegistryDomain::new().expect("net native registry domains exhausted") + }); + *DOMAIN } /// [`next_id`] for the synchronous FFI entry points (`new net.Socket()`, @@ -54,3 +64,24 @@ pub(crate) fn next_id_or_throw() -> i64 { } id } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reserved_net_id_names_the_private_domain() { + let id = next_id(); + assert_ne!(id, perry_ffi::INVALID_HANDLE); + let identity = perry_ffi::handle_registration(id).unwrap(); + assert_eq!(identity.domain(), net_registry_domain()); + assert_ne!(identity.domain(), perry_ffi::handle_registry_domain()); + let lease = + perry_ffi::acquire_handle_registration(identity, perry_ffi::NativeLeaseKind::Operation) + .unwrap(); + perry_ffi::free_handle_id(id); + perry_ffi::drain_quarantined_handles(); + assert!(perry_ffi::handle_registration(id).is_none()); + drop(lease); + } +} diff --git a/crates/perry-ffi/src/handle.rs b/crates/perry-ffi/src/handle.rs index 3848e87955..e345a7ccf1 100644 --- a/crates/perry-ffi/src/handle.rs +++ b/crates/perry-ffi/src/handle.rs @@ -15,7 +15,7 @@ //! # Layout //! //! Single process-wide [`DashMap`] keyed by [`Handle`] (a `i64`). -//! A fresh `i64` is allocated atomically from a counter starting at +//! A fresh `i64` is allocated under the native state mutex from a counter starting at //! 1 — `0` is reserved as `INVALID_HANDLE` so `register_handle` can //! never produce a falsy value (matches JS truthiness semantics //! for type checks like `if (handle)`). Visible ids stop before @@ -29,19 +29,16 @@ //! than its *cumulative* allocation count — while reclaimed ids fit //! within the bounded freelist. Frees beyond [`FREE_HANDLES_CAP`] //! are intentionally discarded, so a burst larger than the cap can -//! still advance [`NEXT_HANDLE`] and consume fresh ids. Ids are +//! still advance the fresh-id counter and consume fresh ids. Ids are //! therefore reused over time but a given id is unique among the //! handles live at any instant — a recycled id is only parked after //! its prior entry was removed from the map. //! -//! A freed id is NOT reusable the instant it is freed: it first sits -//! in a quarantine and is promoted to the freelist only by -//! [`drain_quarantined_handles`], which the host event loop calls -//! once per tick. This deferral closes an ABA / use-after-recycle -//! hazard — a consumer holding a stale bare id (e.g. an HTTP handler's -//! `res` after the response was finalized) would otherwise see its id -//! re-occupied by the next registration within the same tick and -//! silently mutate a different object. See [`QUARANTINED_HANDLES`]. +//! A removed payload first enters native quarantine. Reuse requires a later +//! drain, any deadline to have elapsed, and zero wrapper/operation leases. +//! Slot identity is `(registry domain, registration serial, numeric id)`; the +//! numeric provider ABI stays unchanged. Pending insertion and retiring removal +//! run outside the state mutex while their slots remain unavailable for reuse. //! //! perry-stdlib has its own copy of this same registry (in //! `crates/perry-stdlib/src/common/handle.rs`). They are separate @@ -68,10 +65,13 @@ use std::any::Any; use std::cell::{Cell, RefCell}; use std::ffi::c_void; use std::marker::PhantomData; -use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Mutex; use std::time::Instant; +use crate::{ + NativeLeaseKind, NativeQuarantine, NativeRegistrationIdentity, NativeRegistrationKind, + NativeRegistrationLease, NativeRegistrationRegistry, NativeRegistryDomain, +}; use dashmap::DashMap; use once_cell::sync::Lazy; @@ -88,205 +88,34 @@ static HANDLES: Lazy>> = Lazy::new(Da const FFI_HANDLE_ID_START: Handle = 1; const FFI_HANDLE_ID_END: Handle = 0x40000; -static NEXT_HANDLE: AtomicI64 = AtomicI64::new(FFI_HANDLE_ID_START); - -/// Freelist of ids reclaimed by [`drop_handle`] / [`take_handle`]. -/// -/// Without this, [`register_handle`] only ever bumps [`NEXT_HANDLE`], so a -/// long-lived process that allocates a handle per unit of work — e.g. -/// `perry-ext-http`, which registers a request + response handle per -/// request and `drop_handle`s both once the response flushes — burns through -/// the visible id band (`1 .. 0x40000`) and eventually panics in -/// [`next_fresh_handle_id`], even though only a handful of handles are live at -/// any instant. Recycling freed ids bounds id consumption by the *concurrent* -/// live-handle count rather than the *cumulative* allocation count. -/// -/// Bounded at [`FREE_HANDLES_CAP`] idle ids: a brief spike that frees a huge -/// batch parks at most that many for reuse, and any excess is simply not -/// recycled (the fresh-id path still serves it) so the freelist's own memory -/// can't grow without limit. An id is only ever pushed here *after* it has -/// been removed from [`HANDLES`], so a recycled id is never live in two -/// registrations at once. -static FREE_HANDLES: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); - -/// Upper bound on parked idle ids. The visible band is `0x40000` (262 144) -/// ids; capping the freelist well under that keeps its backing `Vec` small -/// while still covering realistic concurrent in-flight counts (tens of -/// thousands of simultaneous requests). Past the cap, a freed id is dropped on -/// the floor — `register_handle` falls back to a fresh id exactly as it did -/// before recycling existed. const FREE_HANDLES_CAP: usize = 64 * 1024; +static REGISTRATIONS: Lazy = Lazy::new(|| { + NativeRegistrationRegistry::new(FFI_HANDLE_ID_START, FFI_HANDLE_ID_END, FREE_HANDLES_CAP) +}); -/// Quarantine for ids that have just been removed from [`HANDLES`] but are NOT -/// yet eligible for reuse. -/// -/// # Why a quarantine, not direct recycling (ABA / use-after-recycle) -/// -/// The visible handle is a bare integer with no generation/epoch (the i64 ABI -/// is fixed and published — a generation cannot be packed into the id). A -/// consumer that resolves an object purely by id therefore cannot distinguish -/// "the object I was given" from "a *different* object that happens to occupy -/// the same recycled id now." `perry-ext-http` hits this: a request -/// handler can return before `res.end()`, leaving a stale JS-side `res` value -/// (a bare tagged id) outstanding; once that request is finalized its id is -/// freed. If the id were recycled *immediately*, the very next -/// [`register_handle`] (e.g. the next incoming request's response) would -/// re-occupy it, and a late `res.write`/`res.end` from the retired handler -/// would resolve the id to — and mutate — the *new* request's response, -/// bleeding one request's body into another's. Before the freelist existed a -/// freed id stayed dead, so such a stale write was a safe no-op; the freelist -/// removed that safety. The quarantine restores it. -/// -/// A freed id is parked here first and only promoted to [`FREE_HANDLES`] by -/// [`drain_quarantined_handles`], which the host event loop calls once per -/// pump tick. One full tick covers the dominant case: a stale `res.*` that the -/// retired handler defers via a microtask or a same-turn continuation runs -/// before the next tick's drain, and while the id sits in quarantine it maps -/// to nothing in [`HANDLES`], so that stale call re-fetches an empty slot and -/// no-ops (exactly the pre-freelist behavior) instead of corrupting a live -/// object. -/// -/// # The two quarantine tiers -/// -/// The one-tick window only covers ids whose owner is *provably done writing* -/// at free time — an HTTP response that reached `res.end()` (its -/// `writable_ended` is set, so any further `res.write`/`res.end` is a no-op the -/// caller can't ride into a recycled object). For those, one tick is enough: -/// the stale call spends itself against an empty slot on the same turn. -/// -/// But a response can be finalized *without* ever ending — the HTTP reaper -/// frees a parked request's handles when its peer disconnects, or when the -/// owning server is force-closed, neither of which sets `writable_ended`. The -/// handler is still suspended on a slow `await`/`fetch` and may resume many -/// ticks later and call `res.write`. A one-tick quarantine would have promoted -/// (and possibly re-minted) that id long before, so the late write would land -/// on a *live* response — silent cross-request body corruption that is NOT a -/// write-after-end (the handler never called `end()`, so nothing rejects it). -/// -/// For that case the id goes into [`QUARANTINED_UNTIL`] with a *deadline* -/// instead — the request's grace deadline, which the reaper already tracks -/// (Node's `requestTimeout`, default ~300s). The id is held until that deadline -/// passes, by which point the request is definitively dead: a handler that -/// resumes within grace finds its id still parked (the write no-ops against an -/// empty slot); once the deadline elapses no legitimate resume can write, so -/// the id is safe to recycle. This closes the window for arbitrarily-long-async -/// handlers without a per-handle generation (which the fixed i64 ABI forbids). -/// -/// Bound: the deadline-gated quarantine holds at most one id per response per -/// in-flight grace window — the same population the reaper's `IN_FLIGHT` list -/// already bounds — and is capped at [`FREE_HANDLES_CAP`] like every other -/// tier, so it cannot grow without limit. -/// -/// Embedders that never call [`drain_quarantined_handles`] simply never -/// recycle ids — they fall back to fresh-id minting, which is the pre-freelist -/// behavior and is safe (it only forgoes the id-reuse optimization). -static QUARANTINED_HANDLES: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); - -/// Deadline-gated quarantine: ids freed before their owner finished writing -/// (the HTTP reaper's peer-disconnect / force-close paths, where -/// `writable_ended` was never set). Each id is held until `Instant::now()` -/// passes its paired deadline, then promoted to [`FREE_HANDLES`] by -/// [`drain_quarantined_handles`]. See [`QUARANTINED_HANDLES`] for the full -/// rationale (the "two quarantine tiers" section). -static QUARANTINED_UNTIL: Lazy>> = - Lazy::new(|| Mutex::new(Vec::new())); - -/// Pop a recycled id, or `None` when the freelist is empty. -fn pop_free_handle() -> Option { - FREE_HANDLES.lock().unwrap_or_else(|p| p.into_inner()).pop() +/// The authoritative domain for payloads stored in this FFI map. +pub fn handle_registry_domain() -> NativeRegistryDomain { + REGISTRATIONS.domain() } -/// Park a no-longer-live id in the quarantine (NOT the freelist — see -/// [`QUARANTINED_HANDLES`]). Caller MUST have already removed `handle` from -/// [`HANDLES`] (see the safety note above). Drops the id when the quarantine -/// is at [`FREE_HANDLES_CAP`], in which case the id is simply never reused -/// (the fresh-id path still serves it), matching the freelist's overflow -/// behavior. -fn recycle_handle(handle: Handle) { - let mut q = QUARANTINED_HANDLES - .lock() - .unwrap_or_else(|p| p.into_inner()); - push_bounded(&mut q, handle, FREE_HANDLES_CAP); +/// Lookup alone retains no lease; acquire rechecks the full identity. +pub fn handle_registration(handle: Handle) -> Option { + REGISTRATIONS.identity(handle) } -/// Park a no-longer-live id in the DEADLINE-GATED quarantine — held until -/// `Instant::now()` passes `deadline`, not merely until the next tick. For ids -/// freed before their owner finished writing (the HTTP reaper's -/// peer-disconnect / force-close paths); see [`QUARANTINED_UNTIL`]. Caller MUST -/// have already removed `handle` from [`HANDLES`]. Bounded exactly like -/// [`recycle_handle`] — past the cap the id is dropped and the fresh-id path -/// serves future registrations. -fn recycle_handle_until(handle: Handle, deadline: Instant) { - let mut q = QUARANTINED_UNTIL.lock().unwrap_or_else(|p| p.into_inner()); - if q.len() < FREE_HANDLES_CAP { - q.push((handle, deadline)); - } +/// Acquire a counted reference only if this exact registration is still Live. +/// The lease orders id reuse; it does not retain the payload or a payload borrow. +pub fn acquire_handle_registration( + identity: NativeRegistrationIdentity, + kind: NativeLeaseKind, +) -> Option { + REGISTRATIONS.acquire(identity, kind) } -/// Promote quarantined ids to the freelist, making them eligible for reuse by -/// [`register_handle`]. The host event loop calls this once per pump tick, AT -/// THE TOP of the tick — before any of this tick's finalizations quarantine new -/// ids. -/// -/// Two tiers are drained (see [`QUARANTINED_HANDLES`]): -/// -/// * The one-tick tier ([`QUARANTINED_HANDLES`]) is drained whole. An id freed -/// during tick N is released no earlier than the start of tick N+1, by which -/// point tick N's handler microtasks have drained and any stale handle -/// reference has been spent against an empty slot. -/// * The deadline-gated tier ([`QUARANTINED_UNTIL`]) is drained SELECTIVELY: -/// only entries whose deadline has elapsed are promoted; the rest are -/// retained for a future tick. This holds an id freed before its owner -/// finished writing until the request's grace window closes, so a -/// long-suspended handler that resumes within grace still no-ops against an -/// empty slot. -/// -/// Returns the number of ids promoted (for diagnostics/tests). +/// Advance both quarantine tiers. A retired id is reusable only after its +/// deadline/drain boundary and the release of every wrapper/operation lease. pub fn drain_quarantined_handles() -> usize { - let now = Instant::now(); - let one_tick: Vec = { - let mut q = QUARANTINED_HANDLES - .lock() - .unwrap_or_else(|p| p.into_inner()); - std::mem::take(&mut *q) - }; - let elapsed: Vec = { - let mut q = QUARANTINED_UNTIL.lock().unwrap_or_else(|p| p.into_inner()); - // Retain entries still within their grace window; harvest the elapsed - // ones for promotion. - let mut ready = Vec::new(); - q.retain(|(handle, deadline)| { - if now >= *deadline { - ready.push(*handle); - false - } else { - true - } - }); - ready - }; - if one_tick.is_empty() && elapsed.is_empty() { - return 0; - } - let mut free = FREE_HANDLES.lock().unwrap_or_else(|p| p.into_inner()); - let mut promoted = 0; - for handle in one_tick.into_iter().chain(elapsed) { - let before = free.len(); - push_bounded(&mut free, handle, FREE_HANDLES_CAP); - if free.len() != before { - promoted += 1; - } - } - promoted -} - -/// Push `handle` onto `free` unless it is already at `cap`. Factored out so -/// the bounding invariant is unit-testable without touching the process-wide -/// freelist (which concurrent tests churn). -fn push_bounded(free: &mut Vec, handle: Handle, cap: usize) { - if free.len() < cap { - free.push(handle); - } + REGISTRATIONS.drain(Instant::now()) } static ROOT_SCANNERS: Lazy>> = @@ -485,113 +314,56 @@ impl<'a> GcRootVisitor<'a> { pub fn register_handle(value: T) -> Handle { crate::event_pump::ensure_handle_tick_hook_registered(); ensure_handle_exists_probe_registered(); - // Reuse a reclaimed id when one is parked, else mint a fresh one. A - // recycled id was removed from `HANDLES` before being parked, so inserting - // under it here cannot collide with a live registration. Unlike - // `reserve_handle_id`, exhaustion still aborts here: `register_handle` must - // return a live key to insert under, and there is no valid id left to hand - // out. A leaking `register_handle` workload is a bug (its ids are recycled - // by `drop_handle`), so exhaustion here means the concurrent live-handle - // count genuinely exceeded the band. - let handle = pop_free_handle() - .or_else(next_fresh_handle_id) - .unwrap_or_else(|| { - panic!("perry-ffi handle id range exhausted before reserved Web handle bands") - }); - HANDLES.insert(handle, Box::new(value)); + let identity = REGISTRATIONS + .begin_registration(NativeRegistrationKind::Payload) + .expect("perry-ffi native handle registration exhausted"); + let handle = identity.numeric_id(); + // Pending blocks acquisition/reuse while the payload-map lock is held. + let previous = HANDLES.insert(handle, Box::new(value)); + assert!( + previous.is_none(), + "pending native id must have an empty payload slot" + ); + assert!(REGISTRATIONS.publish(identity)); handle } -/// Reserve a globally-unique handle id WITHOUT storing a value in the FFI -/// registry. For a subsystem that keeps its own object map (perry-ext-net's -/// socket registry) but must not alias another library's ids: every ext lib -/// that mints ids privately from 1 collides with the others in the shared -/// `[1, 0x40000)` band, and the composite handle-method dispatch then routes a -/// call to whichever extension *thinks* it owns that number. That is how -/// `socket.on('data', …)` on ext-net socket #1 got claimed by ext-http-server -/// (whose server was also #1) and the mysql2 handshake hung: the listener -/// registered on the HTTP server and the socket's bytes reached nobody. -/// -/// Return [`INVALID_HANDLE`] when the visible id band is exhausted rather than -/// aborting the process (#6441). A reserved id is not recycled until the owning -/// subsystem calls [`free_handle_id`]; a subsystem that never frees (or frees -/// more slowly than it reserves) will eventually drain the band, and a -/// long-running server must degrade that to a recoverable, JS-visible error -/// (e.g. an `EMFILE`-style throw at the socket-alloc site) instead of a crash. -/// The `0` sentinel is safe to route on: callers must NOT register an object -/// under it — `0` is the "no handle" value — so the guard turns exhaustion into -/// a caught error at the boundary, never a phantom id-0 entry. +/// Reserve from the shared numeric pool without inserting an FFI payload. +/// Exhaustion preserves the legacy zero sentinel. pub fn reserve_handle_id() -> Handle { crate::event_pump::ensure_handle_tick_hook_registered(); - pop_free_handle() - .or_else(next_fresh_handle_id) - .unwrap_or(INVALID_HANDLE) + reserve_handle_id_in_domain(handle_registry_domain()) } -/// Free a handle id previously minted by [`reserve_handle_id`], returning it to -/// circulation through the same quarantine [`drop_handle`] uses. -/// -/// [`reserve_handle_id`] hands a subsystem that keeps its OWN object map (e.g. -/// perry-ext-net's socket registry) a globally-unique id without storing -/// anything in [`HANDLES`] — so there is nothing to remove here; this recycles -/// only the *id*. The caller MUST have already dropped the id from its own map -/// and must guarantee no further dispatch will resolve it, exactly the contract -/// [`drop_handle`] places on [`register_handle`] ids. -/// -/// Like every freed id it is parked in the one-tick quarantine -/// ([`QUARANTINED_HANDLES`]) and only promoted to the freelist by -/// [`drain_quarantined_handles`], so a stale bare reference dispatched before -/// the next tick spends against an empty slot instead of aliasing a freshly -/// reserved id — the ABA / use-after-recycle class #6407 fixes. See -/// [`QUARANTINED_HANDLES`]. Passing [`INVALID_HANDLE`] is a no-op, so a caller -/// can free the result of a possibly-exhausted [`reserve_handle_id`] -/// unconditionally. -/// -/// This is the primitive both candidate free-when-unreachable fixes for the -/// reserved-id leak build on (a GC-finalized socket object, or a handle-band -/// liveness sweep — #6441). It performs no reachability analysis itself: a -/// stale JS reference to a `net.Socket` can outlive its `'close'`, so freeing -/// on `'close'` alone is unsafe and left to that follow-up. -pub fn free_handle_id(id: Handle) { - if id == INVALID_HANDLE { - return; - } - recycle_handle(id); +/// Reserve an id for a private payload registry using its authoritative domain. +/// The caller publishes no JavaScript value here and must populate its own map +/// before handing the numeric id to its clients. +pub fn reserve_handle_id_in_domain(domain: NativeRegistryDomain) -> Handle { + let Ok(identity) = + REGISTRATIONS.begin_registration_in_domain(domain, NativeRegistrationKind::Reserved) + else { + return INVALID_HANDLE; + }; + assert!(REGISTRATIONS.publish(identity)); + identity.numeric_id() } -/// Deadline-gated twin of [`free_handle_id`]: holds the reserved id in the -/// [`QUARANTINED_UNTIL`] tier until `Instant::now()` passes `deadline`, rather -/// than merely until the next tick. For a subsystem that frees an id while a -/// stale holder may still resume and dispatch on it within a known grace window -/// (mirrors [`drop_handle_until`]). Passing [`INVALID_HANDLE`] is a no-op. -pub fn free_handle_id_until(id: Handle, deadline: Instant) { - if id == INVALID_HANDLE { - return; - } - recycle_handle_until(id, deadline); +/// Retire a reserved id after its owner removed the payload. Duplicate frees, +/// zero, and attempts to free ordinary payload ids leave the queues unchanged. +pub fn free_handle_id(id: Handle) { + free_reserved_id(id, NativeQuarantine::NextDrain); } -/// Mint a never-before-used id, or `None` once the visible band is exhausted. -/// -/// Returns `None` rather than panicking so callers choose their own exhaustion -/// policy: [`reserve_handle_id`] degrades to a recoverable [`INVALID_HANDLE`] -/// (#6441), while [`register_handle`] — which has no valid key to insert under -/// — still aborts. The atomic keeps advancing past [`FFI_HANDLE_ID_END`] on -/// each post-exhaustion call; that is harmless (every such call maps to `None`) -/// and the `i64` counter cannot realistically wrap. -fn next_fresh_handle_id() -> Option { - fresh_id_or_exhausted(NEXT_HANDLE.fetch_add(1, Ordering::SeqCst)) +/// Retire a reserved id after its owner removes the payload, delaying reuse +/// until a later drain at or after `deadline` with both lease counts at zero. +/// Zero, duplicate retirement, and ordinary payload ids leave queues unchanged. +pub fn free_handle_id_until(id: Handle, deadline: Instant) { + free_reserved_id(id, NativeQuarantine::Until(deadline)); } -/// Classify a raw counter value as a usable fresh id or band-exhausted. -/// Factored out so the exhaustion boundary is unit-testable without advancing -/// the process-wide [`NEXT_HANDLE`] past [`FFI_HANDLE_ID_END`] (which would -/// break every other test in this binary). -fn fresh_id_or_exhausted(raw: Handle) -> Option { - if raw >= FFI_HANDLE_ID_END { - None - } else { - Some(raw) +fn free_reserved_id(id: Handle, quarantine: NativeQuarantine) { + if let Some(identity) = REGISTRATIONS.begin_retirement(id, NativeRegistrationKind::Reserved) { + assert!(REGISTRATIONS.finish_retirement(identity, quarantine)); } } @@ -645,44 +417,33 @@ pub fn get_handle_mut(handle: Handle) -> Option<&'stat /// Remove the handle from the registry and return its value if /// the type matches. After this, the handle is no longer valid. pub fn take_handle(handle: Handle) -> Option { - let removed = HANDLES.remove(&handle); - if removed.is_some() { - // Removed from the registry — the id is dead and safe to recycle. - recycle_handle(handle); - } - removed - .and_then(|(_, boxed)| boxed.downcast::().ok()) - .map(|b| *b) + remove_payload(handle, NativeQuarantine::NextDrain) + .and_then(|boxed| boxed.downcast::().ok()) + .map(|boxed| *boxed) } -/// Remove a handle and drop its value. Returns `true` if the -/// handle existed. +/// Remove the current payload; its native identity outlives retained leases. pub fn drop_handle(handle: Handle) -> bool { - if HANDLES.remove(&handle).is_some() { - // Removed from the registry — the id is dead and safe to recycle. - recycle_handle(handle); - true - } else { - false - } + remove_payload(handle, NativeQuarantine::NextDrain).is_some() } -/// Remove a handle and drop its value, but defer recycling its id until -/// `deadline` rather than the next tick. Returns `true` if the handle existed. -/// -/// For ids freed before their owner finished writing — the HTTP reaper frees a -/// parked response on peer-disconnect / server-force-close without ever setting -/// `writable_ended`, so a handler suspended on a slow `await` can resume many -/// ticks later and write through the bare id. Holding the id until the -/// request's grace deadline keeps it parked (a no-op slot) across that whole -/// window. See [`QUARANTINED_UNTIL`]. +/// Remove the current payload and return whether a payload was removed. +/// Its id can be reused only after a later drain at or after `deadline` with +/// both lease counts at zero; retained leases do not retain the removed payload. pub fn drop_handle_until(handle: Handle, deadline: Instant) -> bool { - if HANDLES.remove(&handle).is_some() { - recycle_handle_until(handle, deadline); - true - } else { - false - } + remove_payload(handle, NativeQuarantine::Until(deadline)).is_some() +} + +fn remove_payload( + handle: Handle, + quarantine: NativeQuarantine, +) -> Option> { + let identity = REGISTRATIONS.begin_retirement(handle, NativeRegistrationKind::Payload)?; + // Retiring blocks acquisition/reuse. Neither payload removal nor its later + // destructor runs under the registration-state mutex. + let removed = HANDLES.remove(&handle).map(|(_, boxed)| boxed); + assert!(REGISTRATIONS.finish_retirement(identity, quarantine)); + removed } /// True if the handle currently maps to a registered object. @@ -954,6 +715,7 @@ mod tests { #[test] fn round_trip_simple_value() { + let _serial = RECYCLE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); let h = register_handle(42_i64); assert_ne!(h, INVALID_HANDLE); assert!(h < FFI_HANDLE_ID_END); @@ -965,6 +727,7 @@ mod tests { #[test] fn mutable_access_persists() { + let _serial = RECYCLE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); struct Counter(u32); let h = register_handle(Counter(0)); with_handle_mut::(h, |c| c.0 += 1).expect("present"); @@ -976,6 +739,7 @@ mod tests { #[test] fn iter_handles_of_mut_updates_matching_values() { + let _serial = RECYCLE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); struct Counter(u32); let a = register_handle(Counter(1)); let b = register_handle(Counter(10)); @@ -995,6 +759,7 @@ mod tests { #[test] fn type_mismatch_returns_none() { + let _serial = RECYCLE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); let h = register_handle(42_i64); // Same handle, wrong type — no value comes back. let r = with_handle::(h, |s| s.clone()); @@ -1004,6 +769,7 @@ mod tests { #[test] fn handles_are_unique() { + let _serial = RECYCLE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); let a = register_handle(1_i32); let b = register_handle(2_i32); assert_ne!(a, b); @@ -1018,23 +784,25 @@ mod tests { // these in parallel, so the reuse-sensitive tests below serialize on // `RECYCLE_TEST_LOCK` and assert the *recycling contract* (a freed id // is reused, fresh-id consumption stays bounded) rather than a fixed id - // value — robust to other tests churning the shared registry, but still - // failing hard against a no-reclaim `drop_handle` (the freed id never + // value, while excluding other registry fixtures from the interval. It + // still fails against a no-reclaim `drop_handle` (the freed id never // lands on the freelist, so it is never reused and id consumption is // unbounded). The bounding invariant is tested in isolation against a - // local freelist via `push_bounded`. + // local native registry with a small queue capacity. // ---------------------------------------------------------------- - static RECYCLE_TEST_LOCK: Mutex<()> = Mutex::new(()); + // Every fixture that drains or observes post-removal ids shares this lock, + // including the sibling registration_tests module and its worker lifetime. + pub(super) static RECYCLE_TEST_LOCK: Mutex<()> = Mutex::new(()); /// Register `value`, reporting whether `register_handle` REUSED a parked /// id rather than minting a fresh one (the recycling contract). A pop - /// leaves [`NEXT_HANDLE`] untouched; a fresh mint advances it, which a + /// leaves the fresh-id counter untouched; a fresh mint advances it, which a /// no-reclaim build would do on every register. Returns `(handle, reused)`. fn register_observing_reuse(value: T) -> (Handle, bool) { - let before = NEXT_HANDLE.load(Ordering::SeqCst); + let before = REGISTRATIONS.next_fresh_id_for_tests(); let handle = register_handle(value); - let reused = NEXT_HANDLE.load(Ordering::SeqCst) == before; + let reused = REGISTRATIONS.next_fresh_id_for_tests() == before; (handle, reused) } @@ -1051,13 +819,10 @@ mod tests { /// that doesn't reclaim. /// /// The bounded retry is what makes the reuse assertion both robust and - /// meaningful on the *process-wide* freelist. The non-serialized registry - /// tests (`round_trip_simple_value` etc.) run in parallel and can pop the - /// very id we just freed in the window before our register — so a single - /// observation can legitimately miss reuse. But recycling guarantees reuse - /// happens *eventually* (we keep re-parking + re-draining ids), whereas a - /// no-reclaim `drop_handle` parks NOTHING, so every attempt mints fresh and - /// the loop exhausts — turning "reuse never happens" into a hard failure. + /// meaningful on the process-wide freelist. All registry fixtures in this + /// binary now share RECYCLE_TEST_LOCK, so no other fixture may drain or + /// consume a row during this interval. The existing bounded retry still + /// distinguishes reuse from a removal path that never queues any ids. fn drop_then_register_reusing(id: Handle, value: T) -> Handle where T: Clone, @@ -1071,8 +836,7 @@ mod tests { if reused { return handle; } - // A parallel test popped our parked id first and we minted fresh; - // drop it (re-quarantining an id) and try again. + // This attempt minted fresh; retire it before checking reuse again. assert!(drop_handle(handle)); } panic!( @@ -1349,19 +1113,26 @@ mod tests { } #[test] - fn freelist_is_bounded() { - // The bounding invariant, tested against a local freelist so it is - // deterministic and can't race the process-wide one. Past `cap`, - // `push_bounded` drops the id on the floor — `register_handle` then - // falls back to a fresh id, exactly as before recycling existed. - let cap = 4; - let mut free: Vec = Vec::new(); - for id in 0..(cap as Handle + 8) { - push_bounded(&mut free, id, cap); + fn ordinary_quarantine_is_bounded() { + let registry = NativeRegistrationRegistry::new(1, 20, 4); + let ids: Vec<_> = (0..12) + .map(|_| { + let identity = registry + .begin_registration(NativeRegistrationKind::Reserved) + .unwrap(); + assert!(registry.publish(identity)); + identity + }) + .collect(); + for identity in ids { + assert!(registry.begin_retirement_of(identity)); + assert!(registry.finish_retirement(identity, NativeQuarantine::NextDrain)); } - assert_eq!(free.len(), cap, "freelist must not grow past the cap"); - // Below the cap it parks every id in order. - assert_eq!(free, vec![0, 1, 2, 3]); + assert_eq!( + registry.drain(Instant::now()), + 4, + "ordinary quarantine retains at most four of twelve retirements" + ); } #[test] @@ -1381,7 +1152,7 @@ mod tests { // bounded handful of fresh ids; recycling keeps OUR contribution near // zero, so the total delta stays tiny in absolute terms. let iterations = FFI_HANDLE_ID_END as usize + 8192; - let before = NEXT_HANDLE.load(Ordering::SeqCst); + let before = REGISTRATIONS.next_fresh_id_for_tests(); for n in 0..iterations { let h = register_handle(n as i64); assert!(drop_handle(h)); @@ -1392,7 +1163,7 @@ mod tests { // no-op and the counter still runs away. drain_quarantined_handles(); } - let after = NEXT_HANDLE.load(Ordering::SeqCst); + let after = REGISTRATIONS.next_fresh_id_for_tests(); let fresh_minted = (after - before) as usize; assert!( fresh_minted < 4096, @@ -1418,17 +1189,18 @@ mod tests { #[test] fn fresh_id_or_exhausted_flags_the_band_boundary() { - // Pure boundary logic, tested without advancing the process-wide - // `NEXT_HANDLE` past `FFI_HANDLE_ID_END` (which would break every - // other test in this binary). Ids strictly below the end are usable; - // the end value and anything past it are exhausted (`None`). - assert_eq!(fresh_id_or_exhausted(1), Some(1)); + let registry = NativeRegistrationRegistry::new(FFI_HANDLE_ID_END - 1, FFI_HANDLE_ID_END, 4); + assert_eq!( + registry + .begin_registration(NativeRegistrationKind::Reserved) + .unwrap() + .numeric_id(), + FFI_HANDLE_ID_END - 1 + ); assert_eq!( - fresh_id_or_exhausted(FFI_HANDLE_ID_END - 1), - Some(FFI_HANDLE_ID_END - 1) + registry.begin_registration(NativeRegistrationKind::Reserved), + Err(crate::NativeRegistrationError::IdExhausted) ); - assert_eq!(fresh_id_or_exhausted(FFI_HANDLE_ID_END), None); - assert_eq!(fresh_id_or_exhausted(FFI_HANDLE_ID_END + 4096), None); } #[test] @@ -1446,7 +1218,7 @@ mod tests { // `assert_ne!` below. (Pre-#6441 it panicked outright.) Either way a // no-recycle build cannot complete the loop. let iterations = FFI_HANDLE_ID_END as usize + 8192; - let before = NEXT_HANDLE.load(Ordering::SeqCst); + let before = REGISTRATIONS.next_fresh_id_for_tests(); for _ in 0..iterations { let id = reserve_handle_id(); assert_ne!( @@ -1458,7 +1230,7 @@ mod tests { // pump's per-tick drain) so the next reserve reuses it. drain_quarantined_handles(); } - let fresh_minted = (NEXT_HANDLE.load(Ordering::SeqCst) - before) as usize; + let fresh_minted = (REGISTRATIONS.next_fresh_id_for_tests() - before) as usize; assert!( fresh_minted < 4096, "fresh-id consumption ({fresh_minted}) over {iterations} \ @@ -1554,6 +1326,7 @@ mod tests { #[test] fn free_handle_id_ignores_invalid_handle() { + let _serial = RECYCLE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); // `reserve_handle_id` returns `INVALID_HANDLE` on exhaustion, so callers // free its result unconditionally; freeing the sentinel must be a no-op // (never park `0` for reuse — it is the "no handle" value). @@ -1567,3 +1340,7 @@ mod tests { drop_handle(h); } } + +#[cfg(test)] +#[path = "handle_registration_tests.rs"] +mod registration_tests; diff --git a/crates/perry-ffi/src/handle_registration_tests.rs b/crates/perry-ffi/src/handle_registration_tests.rs new file mode 100644 index 0000000000..13f42db346 --- /dev/null +++ b/crates/perry-ffi/src/handle_registration_tests.rs @@ -0,0 +1,105 @@ +use super::*; + +#[test] +fn payload_retirement_preserves_leased_identity_and_kind() { + let _serial = super::tests::RECYCLE_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let id = register_handle(41_u64); + let identity = handle_registration(id).unwrap(); + assert_eq!(identity.domain(), handle_registry_domain()); + let wrapper = acquire_handle_registration(identity, NativeLeaseKind::Wrapper).unwrap(); + let operation = acquire_handle_registration(identity, NativeLeaseKind::Operation).unwrap(); + free_handle_id(id); + assert_eq!(with_handle::(id, |value| *value), Some(41)); + assert_eq!(take_handle::(id), Some(41)); + assert!(!handle_exists(id)); + assert!(handle_registration(id).is_none()); + drain_quarantined_handles(); + assert_eq!( + REGISTRATIONS.begin_registration_with_id(id, NativeRegistrationKind::Payload), + Err(crate::NativeRegistrationError::Occupied) + ); + drop(wrapper); + drain_quarantined_handles(); + assert_eq!( + REGISTRATIONS.begin_registration_with_id(id, NativeRegistrationKind::Payload), + Err(crate::NativeRegistrationError::Occupied) + ); + drop(operation); +} + +#[test] +fn worker_retirement_uses_native_state_and_type_mismatch_still_removes() { + let _serial = super::tests::RECYCLE_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let id = register_handle(17_u64); + let identity = handle_registration(id).unwrap(); + let lease = acquire_handle_registration(identity, NativeLeaseKind::Operation).unwrap(); + std::thread::spawn(move || { + assert!(take_handle::(id).is_none()); + assert!(!handle_exists(id)); + drain_quarantined_handles(); + }) + .join() + .unwrap(); + assert!( + acquire_handle_registration(identity, NativeLeaseKind::Wrapper).is_none(), + "worker retirement must end native availability" + ); + assert_eq!( + REGISTRATIONS.begin_registration_with_id(id, NativeRegistrationKind::Reserved), + Err(crate::NativeRegistrationError::Occupied) + ); + drop(lease); + drain_quarantined_handles(); + let replacement = REGISTRATIONS + .begin_registration_with_id(id, NativeRegistrationKind::Reserved) + .expect("worker retirement must complete quarantine before reuse"); + assert_ne!(replacement.serial(), identity.serial()); + assert!(REGISTRATIONS.publish(replacement)); + free_handle_id(replacement.numeric_id()); +} + +#[test] +fn payload_destructor_can_reenter_registration() { + let _serial = super::tests::RECYCLE_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + struct Reenter(std::sync::mpsc::Sender<()>); + impl Drop for Reenter { + fn drop(&mut self) { + let other = register_handle(5_u64); + assert!(drop_handle(other)); + self.0.send(()).unwrap(); + } + } + let (tx, rx) = std::sync::mpsc::channel(); + let id = register_handle(Reenter(tx)); + let worker = std::thread::spawn(move || assert!(drop_handle(id))); + rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("payload destructor must run outside registry locks"); + worker.join().unwrap(); +} + +#[test] +fn duplicate_reserved_free_preserves_one_retirement() { + let _serial = super::tests::RECYCLE_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let domain = NativeRegistryDomain::new().unwrap(); + let id = reserve_handle_id_in_domain(domain); + let identity = handle_registration(id).expect("reservation must create a native registration"); + assert_eq!(identity.domain(), domain); + let lease = acquire_handle_registration(identity, NativeLeaseKind::Operation).unwrap(); + free_handle_id(id); + free_handle_id_until(id, Instant::now()); + assert!(handle_registration(id).is_none()); + drain_quarantined_handles(); + assert_eq!( + REGISTRATIONS.begin_registration_with_id(id, NativeRegistrationKind::Reserved), + Err(crate::NativeRegistrationError::Occupied) + ); + drop(lease); +} diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index 3323fe47b3..5d096074c9 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -62,15 +62,24 @@ pub use types::{ Promise, StringHeader, BIGINT_LIMBS, OBJECT_HEADER_ABI_REVISION, STRING_HEADER_ABI_REVISION, }; +mod native_registration; +pub use native_registration::{ + NativeLeaseKind, NativeQuarantine, NativeRegistrationError, NativeRegistrationIdentity, + NativeRegistrationKind, NativeRegistrationLease, NativeRegistrationRegistry, + NativeRegistryDomain, +}; + mod handle; #[allow(deprecated)] pub use handle::gc_register_root_scanner; pub use handle::{ - drain_quarantined_handles, drop_handle, drop_handle_until, free_handle_id, - free_handle_id_until, gc_register_mutable_root_scanner, gc_register_mutable_root_scanner_named, - get_handle, get_handle_mut, handle_exists, iter_handle_ids_of, iter_handles_of, - iter_handles_of_mut, register_handle, reserve_handle_id, take_handle, with_handle, - with_handle_mut, GcMutableRootScanner, GcRootVisitor, Handle, INVALID_HANDLE, + acquire_handle_registration, drain_quarantined_handles, drop_handle, drop_handle_until, + free_handle_id, free_handle_id_until, gc_register_mutable_root_scanner, + gc_register_mutable_root_scanner_named, get_handle, get_handle_mut, handle_exists, + handle_registration, handle_registry_domain, iter_handle_ids_of, iter_handles_of, + iter_handles_of_mut, register_handle, reserve_handle_id, reserve_handle_id_in_domain, + take_handle, with_handle, with_handle_mut, GcMutableRootScanner, GcRootVisitor, Handle, + INVALID_HANDLE, }; mod jsvalue; diff --git a/crates/perry-ffi/src/native_registration.rs b/crates/perry-ffi/src/native_registration.rs new file mode 100644 index 0000000000..67e7677e10 --- /dev/null +++ b/crates/perry-ffi/src/native_registration.rs @@ -0,0 +1,458 @@ +//! Native registration identities and lease-ordered identifier reuse. +//! +//! One mutex orders slot allocation, publication, retirement, lease acquisition, +//! and quarantine draining. Payload insertion/removal happens outside that +//! mutex: Pending and Retiring slots cannot be acquired or reused. No callback, +//! payload destructor, JavaScript value, or collector slot lives in this module. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; + +/// Identity of one authoritative native registry instance, not an id band. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct NativeRegistryDomain(u64); + +static NEXT_DOMAIN: AtomicU64 = AtomicU64::new(1); +static NEXT_SERIAL: AtomicU64 = AtomicU64::new(1); + +fn issue_serial(counter: &AtomicU64) -> Result { + counter + .try_update(Ordering::Relaxed, Ordering::Relaxed, |next| { + next.checked_add(1) + }) + .map_err(|_| NativeRegistrationError::SerialExhausted) +} + +impl NativeRegistryDomain { + /// Allocate a domain without ever wrapping or reissuing an earlier token. + pub fn new() -> Result { + issue_serial(&NEXT_DOMAIN).map(Self) + } +} + +/// Immutable identity. Provider calls continue to receive `numeric_id()` only. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct NativeRegistrationIdentity { + domain: NativeRegistryDomain, + serial: u64, + numeric_id: i64, +} + +impl NativeRegistrationIdentity { + /// Return the authoritative registry domain recorded for this registration. + pub fn domain(self) -> NativeRegistryDomain { + self.domain + } + /// Return the process-wide issuance serial distinguishing this registration. + pub fn serial(self) -> u64 { + self.serial + } + /// Return the numeric id passed to providers; reuse creates a new identity. + pub fn numeric_id(self) -> i64 { + self.numeric_id + } +} + +/// Reason a native domain or registration could not be allocated. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeRegistrationError { + /// An explicit id lies outside the allocator's half-open numeric range. + InvalidId, + /// The explicit id names a slot that is not reusable with zero leases. + Occupied, + /// No reusable id remains and the fresh-id counter reached the range end. + IdExhausted, + /// The domain or registration counter cannot advance without wrapping. + SerialExhausted, +} + +/// Reserved ids identify payloads stored outside the allocator's payload map. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeRegistrationKind { + /// The id names a value in the allocator adapter's payload map. + Payload, + /// The id is reserved for a payload stored by a separate owner. + Reserved, +} + +/// Which independently counted reference keeps a registration from reuse. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeLeaseKind { + /// A reference retained for the lifetime of a wrapper. + Wrapper, + /// A reference retained for the duration of an operation. + Operation, +} + +/// Both ordinary and deadline retirement require a subsequent explicit drain. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeQuarantine { + /// Eligible at a later drain once both lease counts reach zero. + NextDrain, + /// Eligible at a later drain only at or after this instant and with zero leases. + Until(Instant), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Phase { + Pending, + Live, + Retiring, + Quarantined(NativeQuarantine), + Reusable, + Abandoned, +} + +struct Slot { + identity: NativeRegistrationIdentity, + kind: NativeRegistrationKind, + phase: Phase, + wrappers: usize, + operations: usize, +} + +impl Slot { + fn unleased(&self) -> bool { + self.wrappers == 0 && self.operations == 0 + } +} + +struct State { + next_id: i64, + slots: HashMap, + // Queue rows are non-owning numeric keys; dropping a row drops no lease. + ordinary: Vec, + deadlines: Vec, + free: Vec, +} + +struct Inner { + domain: NativeRegistryDomain, + start: i64, + end: i64, + queue_cap: usize, + state: Mutex, +} + +/// Shared native state for an id allocator and its authoritative registries. +/// Clones name the same allocator; `new` creates an independent domain. +#[derive(Clone)] +pub struct NativeRegistrationRegistry(Arc); + +/// One counted reference to a particular registration, with exactly one release. +/// This retains native identity only, not a removed payload or a payload borrow. +/// It is deliberately not `Clone`; additional acquisition must consult live state. +pub struct NativeRegistrationLease { + registry: NativeRegistrationRegistry, + identity: NativeRegistrationIdentity, + kind: NativeLeaseKind, +} + +impl NativeRegistrationLease { + /// Return the exact registration whose reference count this lease retains. + pub fn identity(&self) -> NativeRegistrationIdentity { + self.identity + } + /// Return which kind of reference is released when this lease is dropped. + pub fn kind(&self) -> NativeLeaseKind { + self.kind + } +} + +impl Drop for NativeRegistrationLease { + fn drop(&mut self) { + let mut state = self.registry.lock(); + let slot = state + .slots + .get_mut(&self.identity.numeric_id) + .expect("leased registration must retain its slot"); + assert_eq!( + slot.identity, self.identity, + "leased registration must not be replaced" + ); + let count = match self.kind { + NativeLeaseKind::Wrapper => &mut slot.wrappers, + NativeLeaseKind::Operation => &mut slot.operations, + }; + *count = count + .checked_sub(1) + .expect("native lease released exactly once"); + } +} + +impl NativeRegistrationRegistry { + /// Create an independent allocator for the half-open id range `start..end`. + /// Each quarantine queue and the freelist has a separate `queue_cap` bound; + /// overflow leaves the affected slot unavailable for reuse. + /// + /// # Panics + /// Panics if the range is empty or includes nonpositive ids, or if domain + /// issuance is exhausted. + pub fn new(start: i64, end: i64, queue_cap: usize) -> Self { + assert!( + start > 0 && start < end, + "native id range must be positive and nonempty" + ); + Self(Arc::new(Inner { + domain: NativeRegistryDomain::new().expect("native registry domains exhausted"), + start, + end, + queue_cap, + state: Mutex::new(State { + next_id: start, + slots: HashMap::new(), + ordinary: Vec::new(), + deadlines: Vec::new(), + free: Vec::new(), + }), + })) + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.0 + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Return the default domain assigned to registrations from this allocator. + pub fn domain(&self) -> NativeRegistryDomain { + self.0.domain + } + + /// Claim a pending slot before inserting a payload outside the state lock. + pub fn begin_registration( + &self, + kind: NativeRegistrationKind, + ) -> Result { + self.begin_registration_in_domain(self.domain(), kind) + } + + /// Allocate from this id pool for a separately owned payload registry. + pub fn begin_registration_in_domain( + &self, + domain: NativeRegistryDomain, + kind: NativeRegistrationKind, + ) -> Result { + let mut state = self.lock(); + let serial = issue_serial(&NEXT_SERIAL)?; + let id = if let Some(&id) = state.free.last() { + let slot = &state.slots[&id]; + assert!(slot.phase == Phase::Reusable && slot.unleased()); + state.free.pop(); + id + } else { + while state.next_id < self.0.end && state.slots.contains_key(&state.next_id) { + state.next_id += 1; + } + if state.next_id >= self.0.end { + return Err(NativeRegistrationError::IdExhausted); + } + let id = state.next_id; + state.next_id += 1; + id + }; + Ok(Self::insert_pending(&mut state, domain, kind, id, serial)) + } + + /// Explicit ids cannot overwrite live, pending, retiring, leased, quarantined, + /// or abandoned slots. An eligible explicit reuse consumes its freelist row. + pub fn begin_registration_with_id( + &self, + id: i64, + kind: NativeRegistrationKind, + ) -> Result { + if !(self.0.start..self.0.end).contains(&id) { + return Err(NativeRegistrationError::InvalidId); + } + let mut state = self.lock(); + if let Some(slot) = state.slots.get(&id) { + if slot.phase != Phase::Reusable || !slot.unleased() { + return Err(NativeRegistrationError::Occupied); + } + } + let serial = issue_serial(&NEXT_SERIAL)?; + state.free.retain(|entry| *entry != id); + Ok(Self::insert_pending( + &mut state, + self.domain(), + kind, + id, + serial, + )) + } + + fn insert_pending( + state: &mut State, + domain: NativeRegistryDomain, + kind: NativeRegistrationKind, + id: i64, + serial: u64, + ) -> NativeRegistrationIdentity { + let identity = NativeRegistrationIdentity { + domain, + serial, + numeric_id: id, + }; + state.slots.insert( + id, + Slot { + identity, + kind, + phase: Phase::Pending, + wrappers: 0, + operations: 0, + }, + ); + identity + } + + /// Publish native availability only after payload insertion is complete. + pub fn publish(&self, identity: NativeRegistrationIdentity) -> bool { + let mut state = self.lock(); + let Some(slot) = state.slots.get_mut(&identity.numeric_id) else { + return false; + }; + if slot.identity != identity || slot.phase != Phase::Pending { + return false; + } + slot.phase = Phase::Live; + true + } + + /// Lookup is not a lease. Acquisition below rechecks this exact identity. + pub fn identity(&self, id: i64) -> Option { + self.lock() + .slots + .get(&id) + .filter(|slot| slot.phase == Phase::Live) + .map(|slot| slot.identity) + } + + /// Publication/operation acquisition is ordered against retirement and reuse. + pub fn acquire( + &self, + identity: NativeRegistrationIdentity, + kind: NativeLeaseKind, + ) -> Option { + let mut state = self.lock(); + let slot = state.slots.get_mut(&identity.numeric_id)?; + if slot.identity != identity || slot.phase != Phase::Live { + return None; + } + let count = match kind { + NativeLeaseKind::Wrapper => &mut slot.wrappers, + NativeLeaseKind::Operation => &mut slot.operations, + }; + *count = count.checked_add(1)?; + Some(NativeRegistrationLease { + registry: self.clone(), + identity, + kind, + }) + } + + /// Close native availability before removing a payload outside this lock. + /// The kind check prevents reserved-id free from retiring a payload slot. + pub fn begin_retirement( + &self, + id: i64, + kind: NativeRegistrationKind, + ) -> Option { + let mut state = self.lock(); + let slot = state.slots.get_mut(&id)?; + if slot.phase != Phase::Live || slot.kind != kind { + return None; + } + slot.phase = Phase::Retiring; + Some(slot.identity) + } + + /// Exact-identity counterpart for callers already carrying a registration. + pub fn begin_retirement_of(&self, identity: NativeRegistrationIdentity) -> bool { + let mut state = self.lock(); + let Some(slot) = state.slots.get_mut(&identity.numeric_id) else { + return false; + }; + if slot.identity != identity || slot.phase != Phase::Live { + return false; + } + slot.phase = Phase::Retiring; + true + } + + /// Complete removal before making a slot eligible for a later drain. + /// Overflow leaves an unavailable tombstone, including for explicit ids. + pub fn finish_retirement( + &self, + identity: NativeRegistrationIdentity, + quarantine: NativeQuarantine, + ) -> bool { + let mut state = self.lock(); + let Some(slot) = state.slots.get(&identity.numeric_id) else { + return false; + }; + if slot.identity != identity || slot.phase != Phase::Retiring { + return false; + } + let queue = match quarantine { + NativeQuarantine::NextDrain => &mut state.ordinary, + NativeQuarantine::Until(_) => &mut state.deadlines, + }; + let phase = if queue.len() < self.0.queue_cap { + queue.push(identity.numeric_id); + Phase::Quarantined(quarantine) + } else { + Phase::Abandoned + }; + state.slots.get_mut(&identity.numeric_id).unwrap().phase = phase; + true + } + + /// One drain boundary. `now` is explicit so deadline tests need no sleeping. + /// Lease inspection and transition to the freelist use the acquisition mutex. + pub fn drain(&self, now: Instant) -> usize { + let mut state = self.lock(); + let ordinary = std::mem::take(&mut state.ordinary); + let deadlines = std::mem::take(&mut state.deadlines); + let mut promoted = 0; + for id in ordinary.into_iter().chain(deadlines) { + let slot = state + .slots + .get_mut(&id) + .expect("quarantine slot must exist"); + let Phase::Quarantined(quarantine) = slot.phase else { + panic!("quarantine row must match slot"); + }; + let elapsed = match quarantine { + NativeQuarantine::NextDrain => true, + NativeQuarantine::Until(deadline) => now >= deadline, + }; + if !elapsed || !slot.unleased() { + match quarantine { + NativeQuarantine::NextDrain => state.ordinary.push(id), + NativeQuarantine::Until(_) => state.deadlines.push(id), + } + continue; + } + if state.free.len() < self.0.queue_cap { + state.slots.get_mut(&id).unwrap().phase = Phase::Reusable; + state.free.push(id); + promoted += 1; + } else { + state.slots.get_mut(&id).unwrap().phase = Phase::Abandoned; + } + } + promoted + } + + #[cfg(test)] + pub(crate) fn next_fresh_id_for_tests(&self) -> i64 { + self.lock().next_id + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-ffi/src/native_registration/tests.rs b/crates/perry-ffi/src/native_registration/tests.rs new file mode 100644 index 0000000000..00a0312123 --- /dev/null +++ b/crates/perry-ffi/src/native_registration/tests.rs @@ -0,0 +1,451 @@ +use super::*; +use std::sync::mpsc; +use std::time::Duration; + +fn registry() -> NativeRegistrationRegistry { + NativeRegistrationRegistry::new(1, 16, 8) +} +fn live( + registry: &NativeRegistrationRegistry, + kind: NativeRegistrationKind, +) -> NativeRegistrationIdentity { + let identity = registry.begin_registration(kind).unwrap(); + assert!(registry.publish(identity)); + identity +} +fn retire( + registry: &NativeRegistrationRegistry, + identity: NativeRegistrationIdentity, + quarantine: NativeQuarantine, +) { + assert!(registry.begin_retirement_of(identity)); + assert!(registry.finish_retirement(identity, quarantine)); +} + +#[test] +fn equal_numeric_ids_keep_domains_distinct() { + let left = registry(); + let right = registry(); + let a = live(&left, NativeRegistrationKind::Payload); + let b = live(&right, NativeRegistrationKind::Payload); + assert_eq!(a.numeric_id(), b.numeric_id()); + assert_ne!( + a.domain(), + b.domain(), + "independent registries must have distinct domains" + ); + assert_ne!(a, b); + assert!(left.acquire(b, NativeLeaseKind::Wrapper).is_none()); + retire(&left, a, NativeQuarantine::NextDrain); + assert_eq!(right.identity(b.numeric_id()), Some(b)); +} + +#[test] +fn reused_numeric_id_receives_a_new_serial() { + let registry = registry(); + let first = live(®istry, NativeRegistrationKind::Payload); + retire(®istry, first, NativeQuarantine::NextDrain); + assert_eq!(registry.drain(Instant::now()), 1); + let second = live(®istry, NativeRegistrationKind::Payload); + assert_eq!(first.numeric_id(), second.numeric_id()); + assert!( + second.serial() > first.serial(), + "re-registration must advance its serial" + ); + assert!(registry.acquire(first, NativeLeaseKind::Wrapper).is_none()); + assert!(!registry.begin_retirement_of(first)); + assert_eq!(registry.identity(second.numeric_id()), Some(second)); +} + +#[test] +fn serial_exhaustion_never_wraps() { + let counter = AtomicU64::new(u64::MAX - 1); + assert_eq!(issue_serial(&counter), Ok(u64::MAX - 1)); + assert_eq!( + issue_serial(&counter), + Err(NativeRegistrationError::SerialExhausted), + "serial exhaustion must reject issuance before wrap" + ); + assert_eq!( + issue_serial(&counter), + Err(NativeRegistrationError::SerialExhausted) + ); + assert_eq!(counter.load(Ordering::Relaxed), u64::MAX); +} + +#[test] +fn pending_and_retiring_slots_cannot_be_acquired_or_reused() { + let registry = registry(); + let identity = registry + .begin_registration(NativeRegistrationKind::Payload) + .unwrap(); + assert!(registry.identity(identity.numeric_id()).is_none()); + assert!(registry + .acquire(identity, NativeLeaseKind::Wrapper) + .is_none()); + assert_eq!( + registry.begin_registration_with_id(identity.numeric_id(), NativeRegistrationKind::Payload), + Err(NativeRegistrationError::Occupied) + ); + assert!(registry.publish(identity)); + assert!(!registry.publish(identity)); + assert!(registry.begin_retirement_of(identity)); + assert!(registry + .acquire(identity, NativeLeaseKind::Operation) + .is_none()); + assert_eq!(registry.drain(Instant::now()), 0); + assert_eq!( + registry.begin_registration_with_id(identity.numeric_id(), NativeRegistrationKind::Payload), + Err(NativeRegistrationError::Occupied) + ); + assert!(registry.finish_retirement(identity, NativeQuarantine::NextDrain)); +} + +#[test] +fn explicit_ids_reserve_the_counter_path_and_reject_occupied_slots() { + let registry = registry(); + let explicit = registry + .begin_registration_with_id(1, NativeRegistrationKind::Payload) + .unwrap(); + assert!(registry.publish(explicit)); + assert_eq!( + registry.begin_registration_with_id(1, NativeRegistrationKind::Payload), + Err(NativeRegistrationError::Occupied), + "explicit insertion must reject an occupied registration" + ); + let ordinary = live(®istry, NativeRegistrationKind::Payload); + assert_eq!( + ordinary.numeric_id(), + 2, + "ordinary allocation must skip an explicit slot" + ); + assert_eq!( + registry.begin_registration_with_id(0, NativeRegistrationKind::Payload), + Err(NativeRegistrationError::InvalidId) + ); + assert_eq!( + registry.begin_registration_with_id(16, NativeRegistrationKind::Payload), + Err(NativeRegistrationError::InvalidId) + ); +} + +#[test] +fn explicit_reuse_consumes_its_freelist_row() { + let registry = registry(); + let first = live(®istry, NativeRegistrationKind::Payload); + retire(®istry, first, NativeQuarantine::NextDrain); + registry.drain(Instant::now()); + let explicit = registry + .begin_registration_with_id(first.numeric_id(), NativeRegistrationKind::Payload) + .unwrap(); + assert!(registry.publish(explicit)); + assert!( + registry.lock().free.is_empty(), + "explicit reuse must consume its freelist row" + ); + assert_ne!( + live(®istry, NativeRegistrationKind::Payload).numeric_id(), + explicit.numeric_id() + ); +} + +#[test] +fn reserved_slots_retire_once_and_cannot_retire_payload_slots() { + let registry = registry(); + let reserved = live(®istry, NativeRegistrationKind::Reserved); + let payload = live(®istry, NativeRegistrationKind::Payload); + assert!(registry + .begin_retirement(payload.numeric_id(), NativeRegistrationKind::Reserved) + .is_none()); + assert!(registry + .begin_retirement(0, NativeRegistrationKind::Reserved) + .is_none()); + assert_eq!( + registry.begin_retirement(reserved.numeric_id(), NativeRegistrationKind::Reserved), + Some(reserved) + ); + assert!( + registry + .begin_retirement(reserved.numeric_id(), NativeRegistrationKind::Reserved) + .is_none(), + "reserved retirement must begin only once" + ); + assert!(registry.finish_retirement(reserved, NativeQuarantine::NextDrain)); + assert!( + !registry.finish_retirement(reserved, NativeQuarantine::NextDrain), + "completed retirement must not queue twice" + ); + assert_eq!(registry.drain(Instant::now()), 1); + assert_eq!(registry.drain(Instant::now()), 0); + assert_eq!(registry.identity(payload.numeric_id()), Some(payload)); +} + +#[test] +fn wrapper_lease_blocks_ordinary_reuse_until_release_and_drain() { + let registry = registry(); + let identity = live(®istry, NativeRegistrationKind::Payload); + let lease = registry + .acquire(identity, NativeLeaseKind::Wrapper) + .unwrap(); + retire(®istry, identity, NativeQuarantine::NextDrain); + assert_eq!( + registry.drain(Instant::now()), + 0, + "leased retired id must stay out of freelist" + ); + assert_eq!( + registry.begin_registration_with_id(identity.numeric_id(), NativeRegistrationKind::Payload), + Err(NativeRegistrationError::Occupied) + ); + drop(lease); + assert_ne!( + live(®istry, NativeRegistrationKind::Payload).numeric_id(), + identity.numeric_id() + ); + assert_eq!(registry.drain(Instant::now()), 1); + assert_eq!( + live(®istry, NativeRegistrationKind::Payload).numeric_id(), + identity.numeric_id() + ); +} + +#[test] +fn operation_lease_outlives_wrapper_lease() { + let registry = registry(); + let identity = live(®istry, NativeRegistrationKind::Payload); + let wrapper = registry + .acquire(identity, NativeLeaseKind::Wrapper) + .unwrap(); + let operation = registry + .acquire(identity, NativeLeaseKind::Operation) + .unwrap(); + assert_eq!(operation.kind(), NativeLeaseKind::Operation); + assert_eq!(operation.identity(), identity); + retire(®istry, identity, NativeQuarantine::NextDrain); + drop(wrapper); + assert_eq!( + registry.drain(Instant::now()), + 0, + "operation lease must block reuse after wrapper release" + ); + drop(operation); + assert_eq!(registry.drain(Instant::now()), 1); +} + +#[test] +fn deadline_and_lease_are_independent_conditions() { + let registry = registry(); + let now = Instant::now(); + let deadline = now + Duration::from_secs(10); + let identity = live(®istry, NativeRegistrationKind::Reserved); + let lease = registry + .acquire(identity, NativeLeaseKind::Wrapper) + .unwrap(); + retire(®istry, identity, NativeQuarantine::Until(deadline)); + assert_eq!( + registry.drain(deadline), + 0, + "elapsed deadline cannot bypass a lease" + ); + drop(lease); + assert_eq!( + registry.drain(now), + 0, + "zero leases cannot bypass a future deadline" + ); + assert_eq!(registry.drain(deadline), 1); + assert_eq!( + live(®istry, NativeRegistrationKind::Reserved).numeric_id(), + identity.numeric_id() + ); +} + +#[test] +fn ordinary_quarantine_requires_a_drain_boundary() { + let registry = registry(); + let identity = live(®istry, NativeRegistrationKind::Payload); + retire(®istry, identity, NativeQuarantine::NextDrain); + assert_ne!( + live(®istry, NativeRegistrationKind::Payload).numeric_id(), + identity.numeric_id() + ); + assert_eq!(registry.drain(Instant::now()), 1); + assert_eq!( + live(®istry, NativeRegistrationKind::Payload).numeric_id(), + identity.numeric_id() + ); +} + +#[test] +fn publication_before_worker_retirement_retains_its_registration() { + let registry = registry(); + let identity = live(®istry, NativeRegistrationKind::Payload); + let worker_registry = registry.clone(); + let (acquired_tx, acquired_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + acquired_rx.recv().unwrap(); + retire(&worker_registry, identity, NativeQuarantine::NextDrain); + assert_eq!( + worker_registry.drain(Instant::now()), + 0, + "publication ordered before retirement must retain its lease" + ); + }); + let lease = registry + .acquire(identity, NativeLeaseKind::Wrapper) + .unwrap(); + acquired_tx.send(()).unwrap(); + worker.join().unwrap(); + assert_eq!(lease.identity(), identity); + drop(lease); + assert_eq!(registry.drain(Instant::now()), 1); +} + +#[test] +fn worker_retirement_before_publication_rejects_acquisition() { + let registry = registry(); + let identity = live(®istry, NativeRegistrationKind::Payload); + let worker_registry = registry.clone(); + std::thread::spawn(move || retire(&worker_registry, identity, NativeQuarantine::NextDrain)) + .join() + .unwrap(); + assert!( + registry + .acquire(identity, NativeLeaseKind::Wrapper) + .is_none(), + "retirement ordered before publication must reject acquisition" + ); + assert_eq!(registry.drain(Instant::now()), 1); + let replacement = live(®istry, NativeRegistrationKind::Payload); + assert_eq!(replacement.numeric_id(), identity.numeric_id()); + assert!(registry + .acquire(identity, NativeLeaseKind::Operation) + .is_none()); +} + +#[test] +fn ordinary_overflow_abandons_reuse_without_retaining_queue_ownership() { + let registry = NativeRegistrationRegistry::new(1, 8, 1); + let queued = live(®istry, NativeRegistrationKind::Payload); + let overflow = live(®istry, NativeRegistrationKind::Payload); + let lease = registry + .acquire(overflow, NativeLeaseKind::Operation) + .unwrap(); + retire(®istry, queued, NativeQuarantine::NextDrain); + retire(®istry, overflow, NativeQuarantine::NextDrain); + assert_eq!( + registry.lock().ordinary, + vec![queued.numeric_id()], + "ordinary quarantine capacity must hold" + ); + assert_eq!( + registry.lock().slots[&overflow.numeric_id()].phase, + Phase::Abandoned + ); + drop(lease); + assert_eq!(registry.drain(Instant::now()), 1); + assert_eq!( + registry.begin_registration_with_id(overflow.numeric_id(), NativeRegistrationKind::Payload), + Err(NativeRegistrationError::Occupied), + "abandoned slot must reject explicit reuse" + ); + let weak = Arc::downgrade(®istry.0); + drop(registry); + assert!( + weak.upgrade().is_none(), + "discarded queue rows must not retain native registry ownership" + ); +} + +#[test] +fn deadline_overflow_and_full_freelist_abandon_reuse() { + let registry = NativeRegistrationRegistry::new(1, 8, 1); + let now = Instant::now(); + let ordinary = live(®istry, NativeRegistrationKind::Reserved); + let deadline = live(®istry, NativeRegistrationKind::Reserved); + let overflow = live(®istry, NativeRegistrationKind::Reserved); + retire(®istry, ordinary, NativeQuarantine::NextDrain); + retire(®istry, deadline, NativeQuarantine::Until(now)); + retire(®istry, overflow, NativeQuarantine::Until(now)); + assert_eq!( + registry.lock().deadlines, + vec![deadline.numeric_id()], + "deadline quarantine capacity must hold" + ); + assert_eq!(registry.drain(now), 1); + assert_eq!( + registry.lock().slots[&deadline.numeric_id()].phase, + Phase::Abandoned + ); + assert_eq!( + registry.lock().slots[&overflow.numeric_id()].phase, + Phase::Abandoned + ); +} + +#[test] +fn lease_owns_native_state_until_its_single_release() { + let registry = registry(); + let identity = live(®istry, NativeRegistrationKind::Reserved); + let lease = registry + .acquire(identity, NativeLeaseKind::Operation) + .unwrap(); + let weak = Arc::downgrade(®istry.0); + retire(®istry, identity, NativeQuarantine::NextDrain); + drop(registry); + assert!(weak.upgrade().is_some()); + drop(lease); + assert!(weak.upgrade().is_none()); +} + +#[test] +fn shared_numeric_pool_preserves_private_registry_domains() { + let registry = registry(); + let private_domain = NativeRegistryDomain::new().unwrap(); + let private = registry + .begin_registration_in_domain(private_domain, NativeRegistrationKind::Reserved) + .unwrap(); + assert!(registry.publish(private)); + let payload = live(®istry, NativeRegistrationKind::Payload); + assert_ne!(private.numeric_id(), payload.numeric_id()); + assert_eq!(private.domain(), private_domain); + assert_eq!(payload.domain(), registry.domain()); +} + +#[test] +fn id_exhaustion_leaves_live_slots_unchanged() { + let registry = NativeRegistrationRegistry::new(1, 2, 1); + let identity = live(®istry, NativeRegistrationKind::Payload); + assert_eq!( + registry.begin_registration(NativeRegistrationKind::Payload), + Err(NativeRegistrationError::IdExhausted) + ); + assert_eq!(registry.identity(1), Some(identity)); +} + +#[test] +fn freelist_capacity_counts_preexisting_free_rows() { + let registry = NativeRegistrationRegistry::new(1, 8, 2); + let first = live(®istry, NativeRegistrationKind::Payload); + let second = live(®istry, NativeRegistrationKind::Payload); + let later = live(®istry, NativeRegistrationKind::Payload); + retire(®istry, first, NativeQuarantine::NextDrain); + retire(®istry, second, NativeQuarantine::NextDrain); + assert_eq!(registry.drain(Instant::now()), 2); + assert_eq!(registry.lock().free.len(), 2); + // Both earlier rows remain free: no allocation consumes them. The empty + // ordinary quarantine admits the third retirement, so only the freelist + // capacity can prevent its promotion on this second drain. + retire(®istry, later, NativeQuarantine::NextDrain); + assert_eq!(registry.lock().ordinary, vec![later.numeric_id()]); + assert_eq!( + registry.drain(Instant::now()), + 0, + "full freelist must reject a later eligible retirement" + ); + assert_eq!(registry.lock().free.len(), 2); + assert_eq!( + registry.lock().slots[&later.numeric_id()].phase, + Phase::Abandoned + ); +} diff --git a/crates/perry-stdlib/src/common/handle.rs b/crates/perry-stdlib/src/common/handle.rs index 62fd3ddd15..1d86be52f1 100644 --- a/crates/perry-stdlib/src/common/handle.rs +++ b/crates/perry-stdlib/src/common/handle.rs @@ -3,14 +3,16 @@ //! Since we can't pass Rust ownership across FFI, we store objects in a //! registry and return integer handles to JavaScript. //! -//! Uses DashMap for lock-free concurrent access, avoiding deadlocks that -//! would occur with Mutex-based approaches. +//! Payload-map locks never overlap the native registration-state mutex. use std::any::Any; -use std::sync::atomic::{AtomicI64, Ordering}; use dashmap::DashMap; use once_cell::sync::Lazy; +use perry_ffi::{ + NativeLeaseKind, NativeQuarantine, NativeRegistrationIdentity, NativeRegistrationKind, + NativeRegistrationLease, NativeRegistrationRegistry, NativeRegistryDomain, +}; /// Handle type - an opaque integer identifier for a managed object pub type Handle = i64; @@ -26,34 +28,57 @@ const COMMON_HANDLE_ID_START: Handle = 1; const COMMON_HANDLE_ID_END: Handle = perry_runtime::value::addr_class::COMMON_HANDLE_BAND_END as Handle; -/// Next handle ID (0 is reserved for invalid/null). The visible low range stops -/// before Web Fetch's pointer-tagged handle band so generic dispatch cannot -/// confuse native wrappers with Fetch Request/Headers/Response handles. -static NEXT_HANDLE: AtomicI64 = AtomicI64::new(COMMON_HANDLE_ID_START); +static REGISTRATIONS: Lazy = Lazy::new(|| { + NativeRegistrationRegistry::new(COMMON_HANDLE_ID_START, COMMON_HANDLE_ID_END, 64 * 1024) +}); -fn next_handle_id() -> Handle { - let handle = NEXT_HANDLE.fetch_add(1, Ordering::SeqCst); - if handle >= COMMON_HANDLE_ID_END { - panic!("common native handle id range exhausted before reserved Web handle bands"); - } - handle +pub fn common_handle_registry_domain() -> NativeRegistryDomain { + REGISTRATIONS.domain() +} + +pub fn common_handle_registration(handle: Handle) -> Option { + REGISTRATIONS.identity(handle) +} + +pub fn acquire_common_handle_registration( + identity: NativeRegistrationIdentity, + kind: NativeLeaseKind, +) -> Option { + REGISTRATIONS.acquire(identity, kind) +} + +/// Native preparation API. Existing publication paths do not drive this drain. +pub fn drain_quarantined_common_handles() -> usize { + REGISTRATIONS.drain(std::time::Instant::now()) } -/// Register an object and get a handle to it pub fn register_handle(value: T) -> Handle { - let handle = next_handle_id(); - HANDLES.insert(handle, Box::new(value)); - if perry_runtime::hot_diag::receiver_repr_on() { - perry_runtime::hot_diag::receiver_repr_note_constructed( - perry_runtime::hot_diag::ReceiverReprFamily::Common, - ); - } - handle + let identity = REGISTRATIONS + .begin_registration(NativeRegistrationKind::Payload) + .expect("common native handle registration exhausted"); + publish_payload(value, identity) } -/// Register an object with a specific ID +/// Explicit insertion rejects any slot that has not completed retirement, +/// quarantine, and lease release. It never replaces an existing payload. pub fn register_handle_with_id(value: T, handle: Handle) -> Handle { - HANDLES.insert(handle, Box::new(value)); + let identity = REGISTRATIONS + .begin_registration_with_id(handle, NativeRegistrationKind::Payload) + .expect("common explicit native handle id is unavailable"); + publish_payload(value, identity) +} + +fn publish_payload( + value: T, + identity: NativeRegistrationIdentity, +) -> Handle { + let handle = identity.numeric_id(); + let previous = HANDLES.insert(handle, Box::new(value)); + assert!( + previous.is_none(), + "pending Common id must have an empty payload slot" + ); + assert!(REGISTRATIONS.publish(identity)); if perry_runtime::hot_diag::receiver_repr_on() { perry_runtime::hot_diag::receiver_repr_note_constructed( perry_runtime::hot_diag::ReceiverReprFamily::Common, @@ -97,15 +122,20 @@ pub fn get_handle_mut(handle: Handle) -> Option<&'stat /// Remove and return a registered object pub fn take_handle(handle: Handle) -> Option { - HANDLES - .remove(&handle) - .and_then(|(_, boxed)| boxed.downcast::().ok()) - .map(|b| *b) + remove_payload(handle) + .and_then(|boxed| boxed.downcast::().ok()) + .map(|boxed| *boxed) } -/// Remove a handle without returning the value (drop it) pub fn drop_handle(handle: Handle) -> bool { - HANDLES.remove(&handle).is_some() + remove_payload(handle).is_some() +} + +fn remove_payload(handle: Handle) -> Option> { + let identity = REGISTRATIONS.begin_retirement(handle, NativeRegistrationKind::Payload)?; + let removed = HANDLES.remove(&handle).map(|(_, boxed)| boxed); + assert!(REGISTRATIONS.finish_retirement(identity, NativeQuarantine::NextDrain)); + removed } /// Check if a handle exists @@ -174,20 +204,26 @@ where /// Clone a handle's value if it implements Clone pub fn clone_handle(handle: Handle) -> Option { - HANDLES.get(&handle).and_then(|entry| { - entry - .value() - .downcast_ref::() - .map(|value| register_handle(value.clone())) - }) + let cloned = HANDLES + .get(&handle) + .and_then(|entry| entry.value().downcast_ref::().cloned()); + cloned.map(register_handle) } +// Adapter tests share ordering with the original handle tests. They do not +// drive the process-global Common drain: other stdlib modules still use bare ids. +#[cfg(test)] +static REGISTRATION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] mod tests { use super::*; #[test] fn test_register_and_get() { + let _serial = REGISTRATION_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); let value = String::from("test"); let handle = register_handle(value); @@ -201,6 +237,9 @@ mod tests { #[test] fn test_take_handle() { + let _serial = REGISTRATION_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); let value = 42i32; let handle = register_handle(value); @@ -212,3 +251,7 @@ mod tests { assert!(retrieved.is_none()); } } + +#[cfg(test)] +#[path = "handle_registration_tests.rs"] +mod registration_tests; diff --git a/crates/perry-stdlib/src/common/handle_registration_tests.rs b/crates/perry-stdlib/src/common/handle_registration_tests.rs new file mode 100644 index 0000000000..34bf3c6839 --- /dev/null +++ b/crates/perry-stdlib/src/common/handle_registration_tests.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn explicit_registration_rejects_live_and_leased_retired_slots() { + let _serial = REGISTRATION_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let id = register_handle(29_u64); + let identity = common_handle_registration(id).unwrap(); + let lease = acquire_common_handle_registration(identity, NativeLeaseKind::Wrapper).unwrap(); + assert!(std::panic::catch_unwind(|| register_handle_with_id(31_u64, id)).is_err()); + assert_eq!(with_handle::(id, |value| *value), Some(29)); + assert!(drop_handle(id)); + // No global drain in this shared-process adapter suite. The local native + // core fixtures separately prove that a held lease blocks an eligible drain. + assert!(std::panic::catch_unwind(|| register_handle_with_id(31_u64, id)).is_err()); + assert!(common_handle_registration(id).is_none()); + drop(lease); +} + +#[test] +fn common_and_ffi_payloads_have_independent_domains() { + let _serial = REGISTRATION_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let id = register_handle(47_u64); + let identity = common_handle_registration(id).unwrap(); + assert_eq!(identity.domain(), common_handle_registry_domain()); + assert_ne!(identity.domain(), perry_ffi::handle_registry_domain()); + assert!(perry_ffi::acquire_handle_registration(identity, NativeLeaseKind::Wrapper).is_none()); + assert_eq!(take_handle::(id), Some(47)); +} + +#[test] +fn cloning_creates_a_new_registration_and_removal_preserves_type_behavior() { + let _serial = REGISTRATION_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + let id = register_handle(String::from("native")); + let cloned = clone_handle::(id).unwrap(); + let first = common_handle_registration(id).unwrap(); + let second = common_handle_registration(cloned).unwrap(); + assert_ne!(first, second); + assert!(second.serial() > first.serial()); + assert!(take_handle::(id).is_none()); + assert!(!handle_exists(id)); + assert_eq!(take_handle::(cloned).as_deref(), Some("native")); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index f36da54d8c..9ca7e16211 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -33,6 +33,12 @@ "Known census holders have explicit verdicts, including PASS1_MARKED's window contract." ], "holders": [ + { + "file": "crates/perry-ffi/src/handle.rs", + "name": "REGISTRATIONS", + "verdict": "not_a_gc_pointer", + "why": "NativeRegistrationRegistry retains registry-domain ids, registration serials, numeric provider ids, lease counts, slot phases, and Instant deadlines. Its mutex/Arc state and queue keys contain no JavaScript values or wrapper addresses. Payloads remain in HANDLES and retain their existing registered scanners." + }, { "file": "crates/perry-ext-exponential-backoff/src/lib.rs", "name": "NEXT_ID", diff --git a/scripts/native_registration_sabotage.py b/scripts/native_registration_sabotage.py new file mode 100644 index 0000000000..5ffbefac32 --- /dev/null +++ b/scripts/native_registration_sabotage.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +"""Check native registration fixtures; --run compiles isolated temporary Rust copies. + +The default mode checks names, assertions, mutation anchors, and adapter fixture +serialization. It does not claim behavioral results. --run requires an authorized +build host and runs no Cargo. It compiles the real native core and Events registry +source; Events uses an empty native payload fixture, not the full runtime/provider. +It also extracts the actual FFI allocation/removal functions into a native fixture +with a Mutex payload map and an inert runtime-probe registration hook. +This checks their transition calls, not DashMap/provider/runtime integration. +Original source is never modified. Every mutant must compile and fail exactly its +named assertion. Compiler errors and unrelated failures never count as evidence. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +from pathlib import Path +import re +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +SOURCES = { + "core": ROOT / "crates/perry-ffi/src/native_registration.rs", + "core_tests": ROOT / "crates/perry-ffi/src/native_registration/tests.rs", + "events": ROOT / "crates/perry-ext-events/src/registry.rs", + "ffi": ROOT / "crates/perry-ffi/src/handle.rs", + "ffi_tests": ROOT / "crates/perry-ffi/src/handle_registration_tests.rs", +} +PREFIX = "native_registration::tests::" +EVENTS_PREFIX = "registry::tests::" +FFI_PREFIX = "handle::registration_tests::" +EXPECTED_COUNTS = {PREFIX: 19, EVENTS_PREFIX: 1, FFI_PREFIX: 4} +EXPECTED_TOTAL = sum(EXPECTED_COUNTS.values()) + + +@dataclass(frozen=True) +class Mutation: + name: str + target: str + anchor: str + replacement: str + fixture: str + message: str + + +MUTATIONS = [ + Mutation( + "leased_promotion", "core", + "if !elapsed || !slot.unleased() {", "if !elapsed {", + PREFIX + "wrapper_lease_blocks_ordinary_reuse_until_release_and_drain", + "leased retired id must stay out of freelist", + ), + Mutation( + "domain_collapse", "core", + "issue_serial(&NEXT_DOMAIN).map(Self)", "issue_serial(&NEXT_DOMAIN).map(|_| Self(1))", + PREFIX + "equal_numeric_ids_keep_domains_distinct", + "independent registries must have distinct domains", + ), + Mutation( + "serial_reuse", "core", + "let identity = NativeRegistrationIdentity {\n domain,\n serial,", + "let identity = NativeRegistrationIdentity {\n domain,\n serial: 1,", + PREFIX + "reused_numeric_id_receives_a_new_serial", + "re-registration must advance its serial", + ), + Mutation( + "operation_omission", "core", + "self.wrappers == 0 && self.operations == 0", "self.wrappers == 0", + PREFIX + "operation_lease_outlives_wrapper_lease", + "operation lease must block reuse after wrapper release", + ), + Mutation( + "deadline_omission", "core", + "NativeQuarantine::Until(deadline) => now >= deadline,", + "NativeQuarantine::Until(_deadline) => true,", + PREFIX + "deadline_and_lease_are_independent_conditions", + "zero leases cannot bypass a future deadline", + ), + Mutation( + "serial_wrap", "core", "next.checked_add(1)", "Some(next.wrapping_add(1))", + PREFIX + "serial_exhaustion_never_wraps", + "serial exhaustion must reject issuance before wrap", + ), + Mutation( + "explicit_occupied_slot", "core", + "if slot.phase != Phase::Reusable || !slot.unleased() {", "if false {", + PREFIX + "explicit_ids_reserve_the_counter_path_and_reject_occupied_slots", + "explicit insertion must reject an occupied registration", + ), + Mutation( + "explicit_counter_overlap", "core", + "while state.next_id < self.0.end && state.slots.contains_key(&state.next_id) {", + "while false {", + PREFIX + "explicit_ids_reserve_the_counter_path_and_reject_occupied_slots", + "ordinary allocation must skip an explicit slot", + ), + Mutation( + "explicit_free_row_retained", "core", + "state.free.retain(|entry| *entry != id);", "// Mutation: retain the consumed free row.", + PREFIX + "explicit_reuse_consumes_its_freelist_row", + "explicit reuse must consume its freelist row", + ), + Mutation( + "reserved_duplicate_begin", "core", + "if slot.phase != Phase::Live || slot.kind != kind {", + "if !matches!(slot.phase, Phase::Live | Phase::Retiring) || slot.kind != kind {", + PREFIX + "reserved_slots_retire_once_and_cannot_retire_payload_slots", + "reserved retirement must begin only once", + ), + Mutation( + "reserved_duplicate_finish", "core", + "if slot.identity != identity || slot.phase != Phase::Retiring {", + "if slot.identity != identity || !matches!(slot.phase, Phase::Retiring | Phase::Quarantined(_)) {", + PREFIX + "reserved_slots_retire_once_and_cannot_retire_payload_slots", + "completed retirement must not queue twice", + ), + Mutation( + "ordinary_queue_overflow", "core", + "if queue.len() < self.0.queue_cap {", "if true {", + PREFIX + "ordinary_overflow_abandons_reuse_without_retaining_queue_ownership", + "ordinary quarantine capacity must hold", + ), + Mutation( + "deadline_queue_overflow", "core", + "if queue.len() < self.0.queue_cap {", "if true {", + PREFIX + "deadline_overflow_and_full_freelist_abandon_reuse", + "deadline quarantine capacity must hold", + ), + Mutation( + "freelist_overflow", "core", + "if state.free.len() < self.0.queue_cap {", "if true {", + PREFIX + "freelist_capacity_counts_preexisting_free_rows", + "full freelist must reject a later eligible retirement", + ), + Mutation( + "abandoned_explicit_reuse", "core", + "if slot.phase != Phase::Reusable || !slot.unleased() {", + "if !matches!(slot.phase, Phase::Reusable | Phase::Abandoned) || !slot.unleased() {", + PREFIX + "ordinary_overflow_abandons_reuse_without_retaining_queue_ownership", + "abandoned slot must reject explicit reuse", + ), + Mutation( + "discarded_queue_ownership", "core", + "} else {\n Phase::Abandoned\n };", + "} else {\n std::mem::forget(self.clone());\n Phase::Abandoned\n };", + PREFIX + "ordinary_overflow_abandons_reuse_without_retaining_queue_ownership", + "discarded queue rows must not retain native registry ownership", + ), + Mutation( + "publication_wins_order", "core", + "if !elapsed || !slot.unleased() {", "if !elapsed {", + PREFIX + "publication_before_worker_retirement_retains_its_registration", + "publication ordered before retirement must retain its lease", + ), + Mutation( + "retirement_wins_order", "core", + "if slot.identity != identity || slot.phase != Phase::Live {\n return None;\n }\n let count = match kind {", + "if slot.identity != identity {\n return None;\n }\n let count = match kind {", + PREFIX + "worker_retirement_before_publication_rejects_acquisition", + "retirement ordered before publication must reject acquisition", + ), + Mutation( + "events_empty_slot_selection", "events", + "pub(super) fn register_event_emitter_handle(value: EventEmitterHandle) -> Handle {", + """pub(super) fn register_event_emitter_handle(value: EventEmitterHandle) -> Handle { + { + let mut slots = lock_event_emitters(); + if let Some(idx) = slots.iter().position(|slot| slot.is_none()) { + slots[idx] = Some(Box::new(value)); + return EVENT_EMITTER_HANDLE_ID_START + idx as Handle; + } + }""", + EVENTS_PREFIX + "retained_event_emitter_registration_blocks_empty_slot_reuse", + "retained Events registration must block empty-slot selection", + ), + + Mutation( + "ffi_worker_retirement_omission", "ffi", + """ let identity = REGISTRATIONS.begin_retirement(handle, NativeRegistrationKind::Payload)?; + // Retiring blocks acquisition/reuse. Neither payload removal nor its later + // destructor runs under the registration-state mutex. + let removed = HANDLES.remove(&handle).map(|(_, boxed)| boxed); + assert!(REGISTRATIONS.finish_retirement(identity, quarantine)); + removed""", + " HANDLES.remove(&handle).map(|(_, boxed)| boxed)", + FFI_PREFIX + "worker_retirement_uses_native_state_and_type_mismatch_still_removes", + "worker retirement must end native availability", + ), + Mutation( + "ffi_retirement_completion_omission", "ffi", + """ let removed = HANDLES.remove(&handle).map(|(_, boxed)| boxed); + assert!(REGISTRATIONS.finish_retirement(identity, quarantine)); + removed""", + """ let removed = HANDLES.remove(&handle).map(|(_, boxed)| boxed); + // Mutation: leave the native registration Retiring. + removed""", + FFI_PREFIX + "worker_retirement_uses_native_state_and_type_mismatch_still_removes", + "worker retirement must complete quarantine before reuse", + ), + Mutation( + "ffi_reserved_metadata_omission", "ffi", + """pub fn reserve_handle_id_in_domain(domain: NativeRegistryDomain) -> Handle { + let Ok(identity) = + REGISTRATIONS.begin_registration_in_domain(domain, NativeRegistrationKind::Reserved) + else { + return INVALID_HANDLE; + }; + assert!(REGISTRATIONS.publish(identity)); + identity.numeric_id() +}""", + """pub fn reserve_handle_id_in_domain(domain: NativeRegistryDomain) -> Handle { + let _ = domain; + FFI_HANDLE_ID_END - 1 +}""", + FFI_PREFIX + "duplicate_reserved_free_preserves_one_retirement", + "reservation must create a native registration", + ), +] + + +def fixture_bodies(text: str) -> dict[str, str]: + """Bound a fixture by the next test item; sufficient for these source files.""" + starts = list(re.finditer(r"#\[test\]\s*fn\s+(\w+)\(\)\s*\{", text)) + return {m.group(1): text[m.end():starts[i + 1].start() if i + 1 < len(starts) else len(text)] + for i, m in enumerate(starts)} + + +def source_checks(source: dict[str, str]) -> set[str]: + fixtures = {PREFIX + name: body for name, body in fixture_bodies(source["core_tests"]).items()} + fixtures.update({EVENTS_PREFIX + name: body for name, body in fixture_bodies(source["events"]).items()}) + fixtures.update({FFI_PREFIX + name: body for name, body in fixture_bodies(source["ffi_tests"]).items()}) + for prefix, count in EXPECTED_COUNTS.items(): + assert sum(name.startswith(prefix) for name in fixtures) == count, (prefix, count) + assert len({m.name for m in MUTATIONS}) == len(MUTATIONS) + for mutation in MUTATIONS: + text = source[mutation.target] + assert text.count(mutation.anchor) == 1, (mutation.name, "anchor count", text.count(mutation.anchor)) + assert mutation.anchor != mutation.replacement + assert mutation.fixture in fixtures, mutation.fixture + assert mutation.message in fixtures[mutation.fixture], (mutation.name, "assertion must be in the named fixture") + + ffi = (ROOT / "crates/perry-ffi/src/handle.rs").read_text() + ffi_tests = (ROOT / "crates/perry-ffi/src/handle_registration_tests.rs").read_text() + # Pure/local fixtures need no global lock. All other original fixtures and + # every new adapter fixture must hold the same guard for their whole body. + local = {"const_pointer_root_slot_is_rewritten", "ordinary_quarantine_is_bounded", + "fresh_id_or_exhausted_flags_the_band_boundary"} + for name, body in fixture_bodies(ffi).items(): + if name not in local: + assert "let _serial = RECYCLE_TEST_LOCK" in body, (name, "missing global FFI ordering guard") + for name, body in fixture_bodies(ffi_tests).items(): + assert "let _serial = super::tests::RECYCLE_TEST_LOCK" in body, (name, "missing sibling FFI ordering guard") + common = (ROOT / "crates/perry-stdlib/src/common/handle.rs").read_text() + common_tests = (ROOT / "crates/perry-stdlib/src/common/handle_registration_tests.rs").read_text() + for name, body in {**fixture_bodies(common), **fixture_bodies(common_tests)}.items(): + assert "let _serial = REGISTRATION_TEST_LOCK" in body, (name, "missing Common ordering guard") + assert "drain_quarantined_common_handles(" not in body, (name, "shared Common fixture must not drain globally") + assert "#[cfg(test)]\nuse perry_ffi::NativeQuarantine;" in source["events"] + assert source["events"].count("NativeQuarantine,") == 0 + return set(fixtures) + + +def checked_run(argv: list[str], timeout: int = 120) -> str: + result = subprocess.run(argv, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) + if result.returncode: + raise RuntimeError(f"command exited {result.returncode}: {argv}\n{result.stdout}") + return result.stdout + + +def harness_source() -> str: + # Copy constants from the real Events source. The fixture changes only its + # payload type, whose fields this registry source never reads. + events_lib = (ROOT / "crates/perry-ext-events/src/lib.rs").read_text() + constants = [] + for name in ("EVENT_EMITTER_HANDLE_ID_START", "EVENT_EMITTER_HANDLE_ID_END"): + match = re.search(rf"const {name}: Handle = (0x[0-9A-Fa-f_]+);", events_lib) + assert match, name + constants.append(f"const {name}: Handle = {match.group(1)};") + return """extern crate self as perry_ffi; +mod native_registration; +pub use native_registration::*; +type Handle = i64; +pub struct EventEmitterHandle; +impl EventEmitterHandle { pub fn new() -> Self { Self } } +pub fn handle_registry_domain() -> NativeRegistryDomain { + static DOMAIN: std::sync::LazyLock = + std::sync::LazyLock::new(|| NativeRegistryDomain::new().unwrap()); + *DOMAIN +} +mod registry; +mod handle; +""" + "\n".join(constants) + "\n" + + + +FFI_FUNCTIONS = ( + "handle_registry_domain", "handle_registration", "acquire_handle_registration", + "drain_quarantined_handles", "register_handle", "reserve_handle_id", + "reserve_handle_id_in_domain", "free_handle_id", "free_handle_id_until", + "free_reserved_id", "take_handle", "drop_handle", "drop_handle_until", + "remove_payload", "handle_exists", +) + + +def extract_function(text: str, name: str) -> str: + """Extract balanced braces from these audited native functions, verbatim. + + These functions' comments/strings contain no unpaired literal braces. The + source preparation gate validates every extracted function before compiling. + """ + match = re.search(rf"(?m)^(?:pub )?fn {name}\b", text) + assert match, name + first = text.index("{", match.end()) + depth = 0 + for end in range(first, len(text)): + if text[end] == "{": + depth += 1 + elif text[end] == "}": + depth -= 1 + if depth == 0: + return text[match.start():end + 1] + raise AssertionError((name, "unclosed function")) + + +def ffi_harness_source(source: str) -> str: + constants = [] + for name in ("FFI_HANDLE_ID_START", "FFI_HANDLE_ID_END", "FREE_HANDLES_CAP"): + match = re.search(rf"(?m)^const {name}: ([^=]+)= ([^;]+);", source) + assert match, name + constants.append(match.group(0)) + prelude = r'''use crate::{NativeLeaseKind, NativeQuarantine, NativeRegistrationIdentity, + NativeRegistrationKind, NativeRegistrationLease, NativeRegistrationRegistry, NativeRegistryDomain}; +use std::any::Any; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::Instant; +type Handle = i64; +const INVALID_HANDLE: Handle = 0; +type Payload = Box; +#[derive(Default)] +struct PayloadMap(Mutex>); +impl PayloadMap { + fn insert(&self, id: Handle, value: Payload) -> Option { + self.0.lock().unwrap().insert(id, value) + } + fn remove(&self, id: &Handle) -> Option<(Handle, Payload)> { + self.0.lock().unwrap().remove(id).map(|value| (*id, value)) + } + fn contains_key(&self, id: &Handle) -> bool { self.0.lock().unwrap().contains_key(id) } +} +static HANDLES: LazyLock = LazyLock::new(PayloadMap::default); +static REGISTRATIONS: LazyLock = LazyLock::new(|| { + NativeRegistrationRegistry::new(FFI_HANDLE_ID_START, FFI_HANDLE_ID_END, FREE_HANDLES_CAP) +}); +fn ensure_handle_exists_probe_registered() {} +fn with_handle R>(id: Handle, f: F) -> Option { + let map = HANDLES.0.lock().unwrap(); + map.get(&id).and_then(|value| value.downcast_ref::().map(f)) +} +#[cfg(test)] +mod tests { pub(super) static RECYCLE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); } +#[cfg(test)] +#[path = "handle_registration_tests.rs"] +mod registration_tests; +''' + return prelude + "\n".join(constants) + "\n" + "\n\n".join(extract_function(source, name) for name in FFI_FUNCTIONS) + "\n" + + +def compile_copy(directory: Path, source: dict[str, str], rustc: str, harness: str) -> Path: + directory.mkdir() + (directory / "native_registration.rs").write_text(source["core"]) + (directory / "native_registration").mkdir() + (directory / "native_registration/tests.rs").write_text(source["core_tests"]) + (directory / "registry.rs").write_text(source["events"]) + (directory / "handle.rs").write_text(ffi_harness_source(source["ffi"])) + (directory / "handle_registration_tests.rs").write_text(source["ffi_tests"]) + harness_file = directory / "harness.rs" + harness_file.write_text(harness) + binary = directory / "native-registration-tests" + checked_run([rustc, "--edition=2021", "--test", str(harness_file), "-o", str(binary)]) + return binary + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", action="store_true", help="compile/execute native and Events fixtures and all mutants") + parser.add_argument("--rustc", default="rustc") + args = parser.parse_args() + source = {name: path.read_text() for name, path in SOURCES.items()} + hashes = {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in SOURCES.items()} + fixtures = source_checks(source) + harness = harness_source() + extracted_ffi = ffi_harness_source(source["ffi"]) + for mutation in MUTATIONS: + if mutation.target == "ffi": + assert mutation.anchor in extracted_ffi, (mutation.name, "mutation must reach extracted adapter code") + print(f"SOURCE CHECK: {EXPECTED_TOTAL} fixtures (19 core + 1 Events + 4 FFI); {len(MUTATIONS)} mutation cases; global fixture ordering", flush=True) + for name, digest in hashes.items(): + print(f"SOURCE SHA256 {name}: {digest}", flush=True) + if not args.run: + print("No Rust compilation or behavioral/sabotage execution requested.") + return + with tempfile.TemporaryDirectory(prefix="native-registration-") as scratch: + root = Path(scratch) + clean = compile_copy(root / "clean", source, args.rustc, harness) + # Also compile without cfg(test): the Events import finding was absent + # from a test-only build. This verifies unused imports in the real file; + # it does not replace the full provider's warnings gate. + checked_run([args.rustc, "--edition=2021", "--crate-type=lib", "-D", "unused-imports", + str(root / "clean/harness.rs"), "-o", str(root / "clean/native-registration.rlib")]) + listing = checked_run([str(clean), "--list"]) + actual = set(re.findall(r"^((?:native_registration::tests|registry::tests|handle::registration_tests)::\w+): test$", listing, re.M)) + assert actual == fixtures, listing + output = checked_run([str(clean), "--test-threads=1"]) + assert f"{EXPECTED_TOTAL} passed; 0 failed" in output, output + print(output, flush=True) + for mutation in MUTATIONS: + changed = dict(source) + changed[mutation.target] = changed[mutation.target].replace(mutation.anchor, mutation.replacement) + mutant = compile_copy(root / mutation.name, changed, args.rustc, harness) + result = subprocess.run([str(mutant), mutation.fixture, "--exact", "--test-threads=1"], + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30) + assert result.returncode != 0, (mutation.name, "mutant unexpectedly passed", result.stdout) + assert ("0 passed; 1 failed" in result.stdout and mutation.message in result.stdout + and f"test {mutation.fixture} ... FAILED" in result.stdout), (mutation.name, "unexpected failure", result.stdout) + print(f"SABOTAGE {mutation.name}: intended assertion failed in {mutation.fixture}; rc={result.returncode}", flush=True) + print(result.stdout, flush=True) + print("CLEAN RESTORE: rerunning the original-source binary", flush=True) + output = checked_run([str(clean), "--test-threads=1"]) + assert f"{EXPECTED_TOTAL} passed; 0 failed" in output, output + print(output, flush=True) + for name, path in SOURCES.items(): + assert hashlib.sha256(path.read_bytes()).hexdigest() == hashes[name], name + print("Original core, tests, Events registry, and FFI adapter source hashes unchanged.") + + +if __name__ == "__main__": + main() From 1ccf4ed984348eb1c837aebf9d7aea9ef04bb9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 8 Sep 2026 19:24:27 +0200 Subject: [PATCH 2/7] chore: number receiver registration changeset Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- ...registration-leases.md => 10000-native-registration-leases.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9990-native-registration-leases.md => 10000-native-registration-leases.md} (100%) diff --git a/changelog.d/9990-native-registration-leases.md b/changelog.d/10000-native-registration-leases.md similarity index 100% rename from changelog.d/9990-native-registration-leases.md rename to changelog.d/10000-native-registration-leases.md From 0f4ddb7abf49a350a5258d3920af9b6e419d865c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 8 Sep 2026 19:53:52 +0200 Subject: [PATCH 3/7] refactor(ffi): extract native registration core Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- Cargo.lock | 5 +++ Cargo.toml | 15 +++---- .../10000-native-registration-leases.md | 5 +++ crates/perry-ffi/Cargo.toml | 8 +++- crates/perry-ffi/src/lib.rs | 3 +- crates/perry-native-registration/Cargo.toml | 15 +++++++ .../src/lib.rs} | 8 +++- .../src}/tests.rs | 0 scripts/gc_runtime_root_holders.json | 6 --- scripts/native_registration_sabotage.py | 7 ++-- scripts/publish/cargo/ffi-publish.mts | 24 +++++++---- scripts/publish_perry_ffi.sh | 40 +++++++++++-------- workspace-architecture.json | 8 +++- 13 files changed, 94 insertions(+), 50 deletions(-) create mode 100644 crates/perry-native-registration/Cargo.toml rename crates/{perry-ffi/src/native_registration.rs => perry-native-registration/src/lib.rs} (98%) rename crates/{perry-ffi/src/native_registration => perry-native-registration/src}/tests.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 216b8a09fd..063d75a4b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6374,6 +6374,7 @@ version = "0.5.1523" dependencies = [ "dashmap 6.2.1", "once_cell", + "perry-native-registration", "perry-runtime", ] @@ -6396,6 +6397,10 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "perry-native-registration" +version = "0.5.1521" + [[package]] name = "perry-parser" version = "0.5.1523" diff --git a/Cargo.toml b/Cargo.toml index eb80573501..b95ff28dd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/perry-dispatch", "crates/perry-runtime", "crates/perry-ffi", + "crates/perry-native-registration", "crates/perry-ext-dotenv", "crates/perry-ext-nanoid", "crates/perry-ext-uuid", @@ -458,15 +459,10 @@ perry-hir = { path = "crates/perry-hir" } perry-transform = { path = "crates/perry-transform" } perry-codegen = { path = "crates/perry-codegen" } perry-dispatch = { path = "crates/perry-dispatch" } -# #1112: `perry-ffi` is published to crates.io so npm-packaged native -# wrappers (per `perry.nativeLibrary` in package.json) can resolve it -# without a sibling Perry checkout. perry-ffi's only direct reference -# to perry-runtime is a `#[cfg(test)]` layout-assertion test which -# moves perry-runtime to a path-only `[dev-dependencies]` entry — -# `cargo publish -p perry-ffi` therefore strips it from the published -# Cargo.toml, and external users never need perry-runtime in their -# Cargo graph (Perry's compiler driver links `libperry_runtime.a` -# into the final binary directly). +# #1112: `perry-ffi` is published for external native wrappers. Its optional +# `runtime-link` edge supplies runtime symbols to in-tree adapter tests. +# The dependency-free registration core sits below both crates so future +# runtime wrappers can share identities without depending on perry-ffi. # `default-features = false` so dependency EDGES (perry-stdlib, ext crates, the # perry binary) don't force perry-runtime's heavy default features (`full`, # `regex-engine`, `temporal`, `url-engine`, `string-normalize`, `intl-segmenter`) @@ -479,6 +475,7 @@ perry-dispatch = { path = "crates/perry-dispatch" } # `wasm-host` must stay out of `default`, generalized to all heavy features. perry-runtime = { path = "crates/perry-runtime", version = "0.5.1011", default-features = false } perry-ffi = { path = "crates/perry-ffi", version = "0.5.1011" } +perry-native-registration = { path = "crates/perry-native-registration", version = "0.5.1521" } perry-ext-dotenv = { path = "crates/perry-ext-dotenv" } perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-uuid = { path = "crates/perry-ext-uuid" } diff --git a/changelog.d/10000-native-registration-leases.md b/changelog.d/10000-native-registration-leases.md index 40f42d45c6..8b9c5e7077 100644 --- a/changelog.d/10000-native-registration-leases.md +++ b/changelog.d/10000-native-registration-leases.md @@ -5,3 +5,8 @@ and bounded id reuse across FFI, Common, and External Events registries. Net reservations retain their private registry domain while sharing the FFI id pool. Explicit Common ids reject occupied or retained slots. JavaScript publication and receiver representation remain unchanged in this preparatory phase. + +Place the dependency-free identity and lease state machine in +`perry-native-registration`, with the existing public types re-exported by +`perry-ffi`. This lets future runtime wrappers share registration state without +reversing FFI's optional `runtime-link` dependency on `perry-runtime`. diff --git a/crates/perry-ffi/Cargo.toml b/crates/perry-ffi/Cargo.toml index c82f7233b6..0ed28a4c0b 100644 --- a/crates/perry-ffi/Cargo.toml +++ b/crates/perry-ffi/Cargo.toml @@ -16,6 +16,8 @@ readme = "README.md" workspace = true [dependencies] +perry-native-registration.workspace = true + # #1112: `perry-runtime` is restored as an OPTIONAL regular dep (not # a dev-dep) so the `runtime-link` feature actually propagates the # linkage into downstream test binaries (e.g. @@ -26,7 +28,7 @@ workspace = true # perry-runtime. The optional + path-only workspace dep means # `cargo publish` will still error against the missing crates.io # entry — see `scripts/publish_perry_ffi.sh` for the maintainer -# workflow (publish perry-runtime FIRST, then perry-ffi). +# workflow (publish perry-native-registration and perry-runtime before perry-ffi). perry-runtime = { workspace = true, optional = true } # Handle registry storage (#466 Phase 5 — `lru-cache`/db wrappers). @@ -35,6 +37,10 @@ perry-runtime = { workspace = true, optional = true } dashmap.workspace = true once_cell.workspace = true +[dev-dependencies] +# Keep the existing allocator observations available only in test builds. +perry-native-registration = { workspace = true, features = ["test-support"] } + [features] default = [] # Opt-in feature used by in-tree extension crates that need to link diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index 5d096074c9..20e8be5940 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -62,8 +62,7 @@ pub use types::{ Promise, StringHeader, BIGINT_LIMBS, OBJECT_HEADER_ABI_REVISION, STRING_HEADER_ABI_REVISION, }; -mod native_registration; -pub use native_registration::{ +pub use perry_native_registration::{ NativeLeaseKind, NativeQuarantine, NativeRegistrationError, NativeRegistrationIdentity, NativeRegistrationKind, NativeRegistrationLease, NativeRegistrationRegistry, NativeRegistryDomain, diff --git a/crates/perry-native-registration/Cargo.toml b/crates/perry-native-registration/Cargo.toml new file mode 100644 index 0000000000..b1aca15754 --- /dev/null +++ b/crates/perry-native-registration/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "perry-native-registration" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Dependency-free native registration identities, leases, and slot transitions shared by Perry adapters." + +[lints] +workspace = true + +[features] +default = [] +# Read-only allocator observation for downstream adapter fixtures. +test-support = [] diff --git a/crates/perry-ffi/src/native_registration.rs b/crates/perry-native-registration/src/lib.rs similarity index 98% rename from crates/perry-ffi/src/native_registration.rs rename to crates/perry-native-registration/src/lib.rs index 67e7677e10..097eba0881 100644 --- a/crates/perry-ffi/src/native_registration.rs +++ b/crates/perry-native-registration/src/lib.rs @@ -5,6 +5,8 @@ //! mutex: Pending and Retiring slots cannot be acquired or reused. No callback, //! payload destructor, JavaScript value, or collector slot lives in this module. +#![deny(missing_docs)] + use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -448,8 +450,10 @@ impl NativeRegistrationRegistry { promoted } - #[cfg(test)] - pub(crate) fn next_fresh_id_for_tests(&self) -> i64 { + /// Read the fresh-id counter for cross-crate adapter fixtures. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn next_fresh_id_for_tests(&self) -> i64 { self.lock().next_id } } diff --git a/crates/perry-ffi/src/native_registration/tests.rs b/crates/perry-native-registration/src/tests.rs similarity index 100% rename from crates/perry-ffi/src/native_registration/tests.rs rename to crates/perry-native-registration/src/tests.rs diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 9ca7e16211..f36da54d8c 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -33,12 +33,6 @@ "Known census holders have explicit verdicts, including PASS1_MARKED's window contract." ], "holders": [ - { - "file": "crates/perry-ffi/src/handle.rs", - "name": "REGISTRATIONS", - "verdict": "not_a_gc_pointer", - "why": "NativeRegistrationRegistry retains registry-domain ids, registration serials, numeric provider ids, lease counts, slot phases, and Instant deadlines. Its mutex/Arc state and queue keys contain no JavaScript values or wrapper addresses. Payloads remain in HANDLES and retain their existing registered scanners." - }, { "file": "crates/perry-ext-exponential-backoff/src/lib.rs", "name": "NEXT_ID", diff --git a/scripts/native_registration_sabotage.py b/scripts/native_registration_sabotage.py index 5ffbefac32..15ad6859cd 100644 --- a/scripts/native_registration_sabotage.py +++ b/scripts/native_registration_sabotage.py @@ -24,8 +24,8 @@ ROOT = Path(__file__).resolve().parents[1] SOURCES = { - "core": ROOT / "crates/perry-ffi/src/native_registration.rs", - "core_tests": ROOT / "crates/perry-ffi/src/native_registration/tests.rs", + "core": ROOT / "crates/perry-native-registration/src/lib.rs", + "core_tests": ROOT / "crates/perry-native-registration/src/tests.rs", "events": ROOT / "crates/perry-ext-events/src/registry.rs", "ffi": ROOT / "crates/perry-ffi/src/handle.rs", "ffi_tests": ROOT / "crates/perry-ffi/src/handle_registration_tests.rs", @@ -281,6 +281,7 @@ def harness_source() -> str: assert match, name constants.append(f"const {name}: Handle = {match.group(1)};") return """extern crate self as perry_ffi; +#[path = "native_registration/lib.rs"] mod native_registration; pub use native_registration::*; type Handle = i64; @@ -372,8 +373,8 @@ def ffi_harness_source(source: str) -> str: def compile_copy(directory: Path, source: dict[str, str], rustc: str, harness: str) -> Path: directory.mkdir() - (directory / "native_registration.rs").write_text(source["core"]) (directory / "native_registration").mkdir() + (directory / "native_registration/lib.rs").write_text(source["core"]) (directory / "native_registration/tests.rs").write_text(source["core_tests"]) (directory / "registry.rs").write_text(source["events"]) (directory / "handle.rs").write_text(ffi_harness_source(source["ffi"])) diff --git a/scripts/publish/cargo/ffi-publish.mts b/scripts/publish/cargo/ffi-publish.mts index 1b3811dda2..841289e703 100644 --- a/scripts/publish/cargo/ffi-publish.mts +++ b/scripts/publish/cargo/ffi-publish.mts @@ -3,11 +3,17 @@ * into the publish-script tree. Maintainer-only: needs ~/.cargo/credentials.toml * with a crates.io API token (`cargo login` once). * - * PREREQUISITE: publish perry-runtime first. perry-ffi has an optional dep on - * perry-runtime gated by the `runtime-link` feature; cargo publish rejects - * perry-ffi until the matching perry-runtime version exists on crates.io. - * perry-runtime's own workspace-crate deps need similar handling (out of - * scope here — the order is documented, not automated). + * PREREQUISITES: publish perry-native-registration, then perry-runtime, + * before perry-ffi. Cargo requires both the registration core and the + * optional runtime-link dependency to resolve on crates.io. + * Dependency publication is manual; for versions not already published: + * cargo publish --dry-run -p perry-native-registration + * cargo publish -p perry-native-registration + * cargo publish -p perry-runtime + * ./scripts/publish_perry_ffi.sh (perry-ffi dry run) + * npm run publish:ffi (perry-ffi publication) + * perry-runtime's own workspace dependencies need prior publication as + * appropriate, outside this entrypoint. * * Usage: npm run publish:ffi */ @@ -27,10 +33,12 @@ async function main(): Promise { } logger.log(`Workspace version: ${version}`) logger.warn( - 'Prerequisite: perry-runtime@' + version + ' must already exist on crates.io ' + - '(perry-ffi optional-deps gate). Publish it first if it does not.', + 'Prerequisites: the Cargo.toml versions of perry-native-registration and ' + + 'perry-runtime must already resolve on crates.io. Publish the registration ' + + 'core first, then the runtime, before perry-ffi.', ) - // Verify the package builds + would publish cleanly, then publish. + // Cargo verifies the package before publishing; the shell entrypoint above + // provides the separate perry-ffi dry run after dependency publication. // --allow-dirty: this script runs from a clean main right after a release // commit, but the worktree may still have generated CHANGELOG/Cargo.lock // changes from the auto-optimize pass. diff --git a/scripts/publish_perry_ffi.sh b/scripts/publish_perry_ffi.sh index 30108b0dab..32f7ccdfd1 100755 --- a/scripts/publish_perry_ffi.sh +++ b/scripts/publish_perry_ffi.sh @@ -3,27 +3,23 @@ # `~/.cargo/credentials.toml` with a crates.io API token (run # `cargo login` once if missing). # -# **Prerequisite — publish perry-runtime first.** perry-ffi has an -# `optional` dep on perry-runtime gated by the `runtime-link` -# feature (used by every in-tree `perry-ext-*` test crate). cargo -# publish rejects the perry-ffi package until the matching -# perry-runtime version exists on crates.io, even though external -# (npm-distributed) consumers will leave the feature off and never -# pull perry-runtime in. Order is: +# **Prerequisites — publish dependency packages before perry-ffi.** The +# required perry-native-registration dependency and the optional perry-runtime +# dependency (runtime-link) must both resolve on crates.io. Cargo checks the +# optional edge even when external wrappers leave that feature off. +# For dependency versions not already available, the order is: # -# 1. cargo publish -p perry-runtime (only the first time the -# version's not yet on -# crates.io; perry-runtime -# itself currently depends -# on other workspace crates -# that would need similar -# publish handling — out of -# scope for this script). -# 2. ./scripts/publish_perry_ffi.sh (this script). +# 1. cargo publish --dry-run -p perry-native-registration +# cargo publish -p perry-native-registration +# 2. cargo publish -p perry-runtime +# (Its own workspace dependencies need prior publication as appropriate; +# dependency publication remains a manual maintainer prerequisite.) +# 3. ./scripts/publish_perry_ffi.sh (perry-ffi dry run) +# ./scripts/publish_perry_ffi.sh --really-publish (perry-ffi publication) # # Run from the workspace root. Ships whatever the # `[workspace.package].version` currently in `Cargo.toml` says, so -# make sure CHANGELOG.md + Cargo.toml were bumped for this release +# make sure the changeset + Cargo.toml were updated for this release # first (the standard workflow already covers that). set -euo pipefail @@ -32,6 +28,8 @@ cd "$WORKSPACE_ROOT" VERSION="$(grep -E '^version = "0\.5\.' Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" echo "Workspace version: ${VERSION}" +echo "Prerequisites: publish perry-native-registration, then perry-runtime, before perry-ffi." +echo "The dependency versions required by Cargo.toml must already resolve on crates.io." # Verify the package builds and would publish cleanly. `--allow-dirty` # is fine because this script is meant to be run from a clean main @@ -41,6 +39,14 @@ echo "Workspace version: ${VERSION}" echo "===> cargo publish --dry-run -p perry-ffi" LOG=/tmp/perry-ffi-publish-dry.log if ! cargo publish --dry-run -p perry-ffi --allow-dirty 2>&1 | tee "$LOG"; then + if grep -q 'no matching package named `perry-native-registration`' "$LOG"; then + echo + echo "ERROR: perry-ffi requires perry-native-registration on crates.io." + echo " First run cargo publish --dry-run -p perry-native-registration," + echo " then cargo publish -p perry-native-registration." + echo " Ensure perry-runtime also resolves, then re-run this script." + exit 2 + fi if grep -q "no matching package named \`perry-runtime\`" "$LOG"; then echo echo "ERROR: perry-ffi can't be published until perry-runtime ${VERSION}" diff --git a/workspace-architecture.json b/workspace-architecture.json index 9f5fc0ee49..9612d79da7 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 80, + "workspace_members": 81, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -67,7 +67,7 @@ ], "decision_counts": { "externalize": 33, - "keep": 42, + "keep": 43, "merge": 1, "remove": 1, "review": 3 @@ -346,6 +346,10 @@ "category": "compiler-core", "decision": "keep" }, + "perry-native-registration": { + "category": "runtime-core", + "decision": "keep" + }, "perry-parser": { "category": "compiler-core", "decision": "keep" From 83c58cd5516ae576c2287a17cd334ceee45356f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 8 Sep 2026 20:15:17 +0200 Subject: [PATCH 4/7] chore: align registration package version Update the new workspace package dependency and lock record after rebasing onto the Train147 workspace version bump. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 063d75a4b7..9e036a7d28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6399,7 +6399,7 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1521" +version = "0.5.1522" [[package]] name = "perry-parser" diff --git a/Cargo.toml b/Cargo.toml index b95ff28dd7..ad850bcdfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -475,7 +475,7 @@ perry-dispatch = { path = "crates/perry-dispatch" } # `wasm-host` must stay out of `default`, generalized to all heavy features. perry-runtime = { path = "crates/perry-runtime", version = "0.5.1011", default-features = false } perry-ffi = { path = "crates/perry-ffi", version = "0.5.1011" } -perry-native-registration = { path = "crates/perry-native-registration", version = "0.5.1521" } +perry-native-registration = { path = "crates/perry-native-registration", version = "0.5.1522" } perry-ext-dotenv = { path = "crates/perry-ext-dotenv" } perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-uuid = { path = "crates/perry-ext-uuid" } From 5db551df7847ef650f147b1b25c3c41ec8ff08d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 8 Sep 2026 20:58:29 +0200 Subject: [PATCH 5/7] test(runtime): follow consolidated Common publisher The two Common registration entrypoints now share one payload publisher and one receiver-diagnostic bump, so keep the source witness aligned with that single audited site. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- crates/perry-runtime/src/hot_diag/receiver_repr.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index d00cce363a..e7614939fd 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -463,7 +463,9 @@ mod tests { let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); let workspace = manifest.parent().and_then(Path::parent).unwrap(); let witnesses = [ - ("crates/perry-stdlib/src/common/handle.rs", "Common", 2), + // Both Common registration entrypoints now funnel through one + // payload publisher and therefore share one diagnostic bump. + ("crates/perry-stdlib/src/common/handle.rs", "Common", 1), ("crates/perry-stdlib/src/fetch/mod.rs", "Fetch", 1), ("crates/perry-stdlib/src/zlib.rs", "Zlib", 1), ("crates/perry-runtime/src/proxy.rs", "Proxy", 1), From 3afb7367c39683713111a064814fb08a973bdec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 9 Sep 2026 00:31:27 +0200 Subject: [PATCH 6/7] Cover private-domain handle reservations in tick setup Install the handle-recycling tick hook at the shared reservation entrypoint, add a fresh-process runtime witness for that path, and keep the extracted native-registration fixture buildable with an inert event-pump seam. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- crates/perry-ffi/src/event_pump.rs | 60 +++++++++++++++++++++++++ crates/perry-ffi/src/handle.rs | 2 +- scripts/native_registration_sabotage.py | 8 ++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/perry-ffi/src/event_pump.rs b/crates/perry-ffi/src/event_pump.rs index 074ebf9b7c..447f53ebe9 100644 --- a/crates/perry-ffi/src/event_pump.rs +++ b/crates/perry-ffi/src/event_pump.rs @@ -129,6 +129,66 @@ mod tests { 0 } + #[test] + fn private_domain_reservation_installs_outer_tick_recycling_hook() { + const CHILD: &str = "PERRY_TEST_PRIVATE_DOMAIN_TICK_HOOK_CHILD"; + if std::env::var_os(CHILD).as_deref() != Some(std::ffi::OsStr::new("1")) { + // Hook registration is process-wide. Start a child which runs only + // this test so an ordinary payload registration cannot conceal a + // missing hook on the private-domain entrypoint. + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "event_pump::tests::private_domain_reservation_installs_outer_tick_recycling_hook", + ]) + .env(CHILD, "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let mut timed_out = false; + while child.try_wait().unwrap().is_none() { + if std::time::Instant::now() >= deadline { + timed_out = true; + let _ = child.kill(); + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!( + !timed_out && output.status.success(), + "private-domain lifecycle child timed_out={timed_out}, status={}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + + let domain = crate::NativeRegistryDomain::new().unwrap(); + let retired = crate::reserve_handle_id_in_domain(domain); + assert_ne!(retired, 0); + crate::free_handle_id(retired); + let held = crate::reserve_handle_id_in_domain(domain); + assert_ne!(held, retired, "retired ids wait for the next outer tick"); + + // Exercise the real runtime tick. The test must not call the drain + // helper directly because the registration edge is the behavior under + // test. + unsafe { js_run_stdlib_pump() }; + let reused = crate::reserve_handle_id_in_domain(domain); + assert_eq!( + reused, retired, + "private-domain reservation installs the tick hook" + ); + + crate::free_handle_id(held); + crate::free_handle_id(reused); + unsafe { js_run_stdlib_pump() }; + } + #[test] fn outer_ticks_recycle_handles_before_callbacks_without_http() { const CHILD: &str = "PERRY_TEST_OUTER_TICK_LIFECYCLE_CHILD"; diff --git a/crates/perry-ffi/src/handle.rs b/crates/perry-ffi/src/handle.rs index e345a7ccf1..0b328c0ece 100644 --- a/crates/perry-ffi/src/handle.rs +++ b/crates/perry-ffi/src/handle.rs @@ -331,7 +331,6 @@ pub fn register_handle(value: T) -> Handle { /// Reserve from the shared numeric pool without inserting an FFI payload. /// Exhaustion preserves the legacy zero sentinel. pub fn reserve_handle_id() -> Handle { - crate::event_pump::ensure_handle_tick_hook_registered(); reserve_handle_id_in_domain(handle_registry_domain()) } @@ -339,6 +338,7 @@ pub fn reserve_handle_id() -> Handle { /// The caller publishes no JavaScript value here and must populate its own map /// before handing the numeric id to its clients. pub fn reserve_handle_id_in_domain(domain: NativeRegistryDomain) -> Handle { + crate::event_pump::ensure_handle_tick_hook_registered(); let Ok(identity) = REGISTRATIONS.begin_registration_in_domain(domain, NativeRegistrationKind::Reserved) else { diff --git a/scripts/native_registration_sabotage.py b/scripts/native_registration_sabotage.py index 15ad6859cd..a2de915e1a 100644 --- a/scripts/native_registration_sabotage.py +++ b/scripts/native_registration_sabotage.py @@ -204,6 +204,7 @@ class Mutation: Mutation( "ffi_reserved_metadata_omission", "ffi", """pub fn reserve_handle_id_in_domain(domain: NativeRegistryDomain) -> Handle { + crate::event_pump::ensure_handle_tick_hook_registered(); let Ok(identity) = REGISTRATIONS.begin_registration_in_domain(domain, NativeRegistrationKind::Reserved) else { @@ -213,6 +214,7 @@ class Mutation: identity.numeric_id() }""", """pub fn reserve_handle_id_in_domain(domain: NativeRegistryDomain) -> Handle { + crate::event_pump::ensure_handle_tick_hook_registered(); let _ = domain; FFI_HANDLE_ID_END - 1 }""", @@ -292,6 +294,12 @@ def harness_source() -> str: std::sync::LazyLock::new(|| NativeRegistryDomain::new().unwrap()); *DOMAIN } +// The extracted allocation functions now install the runtime tick hook. This +// native-only fixture has no runtime event pump; its inert seam keeps the +// source extraction exact while runtime-linked tests own lifecycle behavior. +mod event_pump { + pub(crate) fn ensure_handle_tick_hook_registered() {} +} mod registry; mod handle; """ + "\n".join(constants) + "\n" From 5b074a00329529c78b7b13250c27f19fbca93a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 9 Sep 2026 00:42:31 +0200 Subject: [PATCH 7/7] Refresh native registration lock entry after rebase Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 9e036a7d28..96f18fad41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6399,7 +6399,7 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1522" +version = "0.5.1523" [[package]] name = "perry-parser"