diff --git a/Cargo.lock b/Cargo.lock index 216b8a09fd..96f18fad41 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.1523" + [[package]] name = "perry-parser" version = "0.5.1523" diff --git a/Cargo.toml b/Cargo.toml index eb80573501..ad850bcdfc 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.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" } diff --git a/changelog.d/10000-native-registration-leases.md b/changelog.d/10000-native-registration-leases.md new file mode 100644 index 0000000000..8b9c5e7077 --- /dev/null +++ b/changelog.d/10000-native-registration-leases.md @@ -0,0 +1,12 @@ +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. + +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-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/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/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 3848e87955..0b328c0ece 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 { + reserve_handle_id_in_domain(handle_registry_domain()) +} + +/// 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 { crate::event_pump::ensure_handle_tick_hook_registered(); - pop_free_handle() - .or_else(next_fresh_handle_id) - .unwrap_or(INVALID_HANDLE) + let Ok(identity) = + REGISTRATIONS.begin_registration_in_domain(domain, NativeRegistrationKind::Reserved) + else { + return INVALID_HANDLE; + }; + assert!(REGISTRATIONS.publish(identity)); + identity.numeric_id() } -/// 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. +/// 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) { - if id == INVALID_HANDLE { - return; - } - recycle_handle(id); + free_reserved_id(id, NativeQuarantine::NextDrain); } -/// 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. +/// 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) { - if id == INVALID_HANDLE { - return; - } - recycle_handle_until(id, deadline); + free_reserved_id(id, NativeQuarantine::Until(deadline)); } -/// 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)) -} - -/// 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..20e8be5940 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -62,15 +62,23 @@ pub use types::{ Promise, StringHeader, BIGINT_LIMBS, OBJECT_HEADER_ABI_REVISION, STRING_HEADER_ABI_REVISION, }; +pub use perry_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-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-native-registration/src/lib.rs b/crates/perry-native-registration/src/lib.rs new file mode 100644 index 0000000000..097eba0881 --- /dev/null +++ b/crates/perry-native-registration/src/lib.rs @@ -0,0 +1,462 @@ +//! 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. + +#![deny(missing_docs)] + +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 + } + + /// 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 + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-native-registration/src/tests.rs b/crates/perry-native-registration/src/tests.rs new file mode 100644 index 0000000000..00a0312123 --- /dev/null +++ b/crates/perry-native-registration/src/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-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), 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/native_registration_sabotage.py b/scripts/native_registration_sabotage.py new file mode 100644 index 0000000000..a2de915e1a --- /dev/null +++ b/scripts/native_registration_sabotage.py @@ -0,0 +1,451 @@ +#!/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-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", +} +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 { + crate::event_pump::ensure_handle_tick_hook_registered(); + 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 { + crate::event_pump::ensure_handle_tick_hook_registered(); + 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; +#[path = "native_registration/lib.rs"] +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 +} +// 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" + + + +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").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"])) + (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() 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"