diff --git a/changelog.d/PENDING-native-payload-lifecycle.md b/changelog.d/PENDING-native-payload-lifecycle.md new file mode 100644 index 0000000000..c4b7a3179b --- /dev/null +++ b/changelog.d/PENDING-native-payload-lifecycle.md @@ -0,0 +1,38 @@ +Native payload close now releases the installed resource while preserving the +object's permanent cell and traced owner edge. Only GC sweep and worker +teardown finalize the cell. A closed instance can reopen through `attach` +without changing object identity, properties, prototype or existing OwnerLink +tokens; `alloc_closed` supports instances born without a resource. + +The runtime adds lifecycle/attach state checks, process-wide OpenSerial stamps, +and `link_event_owner` for terminal events queued before release. Native-call +finish returns `CallEnd::Closed` for a deferred close and preserves a callback +exception as `CallEnd::Threw`. Worker teardown also finalizes pinned native +cells whose pending refs expire with the worker. Cell size and the +`payload_mut`/`link_owner` hot paths are unchanged. + +The native-payload pattern documents per-item refs, dispatch-time listeners, +serial checks for stale children/completions, reopen and queue teardown rules. +Runtime witnesses cover release/sweep, finalized attach rejection, worker queue +discard, throw-before-close priority, same-cell reopen, moving closed owners, +and 200,000 release cycles. The existing T1–T12 callback witnesses retain all +14 sabotage checks; lifecycle witnesses add four sabotage checks. + +The moving-getter stream unit fixture now initializes the GC root scanners +before deliberately collecting, matching generated-program startup. This +fixes its inherited failure on main without changing stream production code. +The DOMException worker-exit fixture initializes its observing heap before +the worker runs, so allocator reuse cannot make the dead worker's stale +header look like a new allocation owned by the observer. + +Integration with current main retains `alloc_with_prototype` and provides +`attach_to_object` for existing subclass objects. Reopening preserves the +existing cell and rejects open or finalized cells. AsyncHook's unpublished +record index is initialized in its existing open payload, rather than +replacing that payload through attach and retiring the new record. + +The RSS attribution and identical-binary control are recorded in +`docs/native-payload-lifecycle-rss.md`. `scripts/runtime_rss_ab.py` prepares +each executable's file cache identically before interleaved Linux RSS runs; +copied and linked copies of the same ELF can otherwise differ by over 10 MiB +of clean file-backed RSS even with anonymous THP disabled. diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 3d4b0e77bc..8175e5dbcc 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -410,7 +410,7 @@ pub(crate) fn test_link_async_resource_subclass( trigger_async_id: 0, }); let value = crate::value::js_nanbox_pointer(receiver as i64); - crate::native_payload::attach( + crate::native_payload::attach_to_object( value, &ASYNC_RESOURCE_FAMILY, AsyncResourcePayload { ids }, @@ -847,25 +847,44 @@ fn register_hook(callbacks: HookCallbacks, track_promises: bool) -> usize { fn ensure_async_hook_index(receiver: i64) -> Option { let value = crate::value::js_nanbox_pointer(receiver); - match unsafe { + let needs_attach = match unsafe { crate::native_payload::payload_mut_attached::(value, &ASYNC_HOOK_FAMILY) } { - Ok(payload) if payload.index != usize::MAX => Some(payload.index), - Ok(_) | Err(crate::native_payload::PayloadMiss::Closed) => { - let scope = crate::gc::RuntimeHandleScope::new(); - let receiver = scope.root_nanbox_f64(value); - let (callbacks, track_promises) = callbacks_from_hook_state(receiver.get_nanbox_f64())?; - let index = register_hook(callbacks, track_promises); - crate::native_payload::attach( + Ok(payload) if payload.index != usize::MAX => return Some(payload.index), + // createHook already owns an OPEN payload whose record is unpublished. + // Initialize that payload in place: attach rejects OPEN cells, and + // dropping its rejected input would retire the record we just made. + Ok(_) => false, + Err(crate::native_payload::PayloadMiss::Closed) => true, + Err(crate::native_payload::PayloadMiss::Foreign) => return None, + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(value); + let (callbacks, track_promises) = callbacks_from_hook_state(receiver.get_nanbox_f64())?; + let index = register_hook(callbacks, track_promises); + if needs_attach { + if !crate::native_payload::attach_to_object( + receiver.get_nanbox_f64(), + &ASYNC_HOOK_FAMILY, + AsyncHookPayload { index }, + 0, + ) { + return None; + } + } else { + let payload = unsafe { + crate::native_payload::payload_mut_attached::( receiver.get_nanbox_f64(), &ASYNC_HOOK_FAMILY, - AsyncHookPayload { index }, - 0, - ); - Some(index) - } - Err(crate::native_payload::PayloadMiss::Foreign) => None, + ) + }; + let Ok(payload) = payload else { + retire_hook(index); + return None; + }; + payload.index = index; } + Some(index) } #[no_mangle] @@ -1351,7 +1370,7 @@ fn new_async_resource_with_public_value( } let public = scope.root_nanbox_f64(match public_resource { Some(owner) => { - crate::native_payload::attach( + crate::native_payload::attach_to_object( owner.get_nanbox_f64(), &ASYNC_RESOURCE_FAMILY, payload, diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index cd1c3de623..e7dff74521 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -551,7 +551,7 @@ unsafe fn visit_gc_rewrite_slot_descriptors_with( GcRewriteDescriptorKind::NativeHandle => { let cell = user_ptr as *mut crate::native_handle::NativeHandleHeader; // One enumerator serves mark, relocation and dirty-slot rescan. - // Closed cells no longer keep an owner alive. + // Released cells keep tracing their owner; only finalized cells stop. if (*cell).finalized == 0 && (*cell).owner != 0 { visit(fixed_slot(&mut (*cell).owner as *mut u64)); } diff --git a/crates/perry-runtime/src/gc/malloc.rs b/crates/perry-runtime/src/gc/malloc.rs index ee13393531..86ba5b9210 100644 --- a/crates/perry-runtime/src/gc/malloc.rs +++ b/crates/perry-runtime/src/gc/malloc.rs @@ -133,8 +133,9 @@ impl MallocState { /// tables) are out of scope for this mechanical fix. This also avoids the /// re-entrant `MALLOC_STATE.with(...)` the sweep bookkeeping performs. /// - /// Pinned objects are skipped, mirroring `process_sweep_header`, so a - /// cross-thread promise pinned for an in-flight result is never yanked. + /// Pinned non-native objects are skipped, so cross-thread promises stay + /// alive. Native cells belong to this thread; pending queue refs expire + /// with the worker and cannot prevent its payload cleanup. fn free_all_tracked_objects(&mut self) -> u64 { let mut freed_bytes: u64 = 0; for header in self.objects.drain(..) { @@ -145,7 +146,9 @@ impl MallocState { // (GcHeader-prefixed block) until freed here; this loop frees each // exactly once and the thread is exiting, so no concurrent access. unsafe { - if (*header).gc_flags & GC_FLAG_PINNED != 0 { + if (*header).gc_flags & GC_FLAG_PINNED != 0 + && (*header).obj_type != GC_TYPE_NATIVE_HANDLE + { continue; } let total_size = (*header).size as usize; diff --git a/crates/perry-runtime/src/gc/tests/copying/latch.rs b/crates/perry-runtime/src/gc/tests/copying/latch.rs index ca59f89ee3..6a48058c2b 100644 --- a/crates/perry-runtime/src/gc/tests/copying/latch.rs +++ b/crates/perry-runtime/src/gc/tests/copying/latch.rs @@ -355,7 +355,7 @@ fn pin_object_non_young_call_sites_are_never_young() { )); crate::gc::pin_object_non_young(cell_header); crate::gc::unpin_object(cell_header); - crate::native_handle::native_handle_dispose_rust_payload(cell); + crate::native_handle::native_handle_release_rust_payload(cell); // Control: a plain nursery object IS young, so the predicate the two // assertions above rely on is not vacuously false for everything. diff --git a/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs b/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs index d7e09cfc24..00894d2e7d 100644 --- a/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs +++ b/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs @@ -3,7 +3,7 @@ use super::super::*; use super::support::*; use crate::native_payload::{ - self as np, CloseOutcome, NativePayloadFamily, OwnerLink, PayloadMiss, + self as np, CallEnd, CloseOutcome, NativePayloadFamily, OwnerLink, PayloadMiss, }; use crate::value::TAG_UNDEFINED; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -269,7 +269,7 @@ fn t4_t5_throw_identity_first_throw_wins_and_c_returns() { }); assert_eq!(returned, Ok(true), "throw must never cross C"); assert_eq!(CALLS.load(Ordering::SeqCst), 1); - assert_eq!(guard.finish().unwrap_err().to_bits(), err_value.to_bits()); + assert_eq!(thrown(guard.finish()).to_bits(), err_value.to_bits()); assert_eq!(crate::exception::current_try_depth(), depth); assert_eq!(unsafe { (*cell(link)).busy }, 0); let guard = np::enter(value.get_nanbox_f64(), &FAMILY).unwrap(); @@ -313,7 +313,7 @@ fn t4_native_validation_parks_typeerror_without_a_throw() { np::set_pending_exception(value.get_nanbox_f64(), 99.0), Err(()) ); - assert_eq!(guard.finish().unwrap_err().to_bits(), error_bits.to_bits()); + assert_eq!(thrown(guard.finish()).to_bits(), error_bits.to_bits()); } #[test] @@ -378,9 +378,9 @@ fn t7_t8_close_defers_until_reentrant_calls_return() { assert_eq!(unsafe { np::link_owner(link) }, None); assert_eq!(np::close(value, &FAMILY), CloseOutcome::AlreadyClosed); assert_eq!(DROPS.load(Ordering::SeqCst), 0); - assert_eq!(inner.finish(), Ok(())); + assert_eq!(inner.finish(), Err(CallEnd::Closed)); assert_eq!(DROPS.load(Ordering::SeqCst), 0); - assert_eq!(outer.finish(), Ok(())); + assert_eq!(outer.finish(), Err(CallEnd::Closed)); assert_eq!(DROPS.load(Ordering::SeqCst), 1); assert_eq!(unsafe { (*cell(link)).busy }, 0); } @@ -467,7 +467,7 @@ extern "C" fn nested(_: *const crate::closure::ClosureHeader, this: crate::closu if np::callback_sabotage("conversion") { crate::exception::js_throw(43.0); } - if let Err(err) = guard.finish() { + if let Err(CallEnd::Threw(err)) = guard.finish() { crate::exception::js_throw(err); } }); @@ -556,3 +556,13 @@ fn every_sabotage_makes_its_runtime_witness_red() { eprintln!("callback sabotage {fault}: RED ({})", output.status); } } + +fn thrown(result: Result<(), CallEnd>) -> f64 { + match result { + Err(CallEnd::Threw(err)) => err, + other => panic!("expected callback throw, got {other:?}"), + } +} + +#[path = "native_payload_lifecycle.rs"] +mod lifecycle; diff --git a/crates/perry-runtime/src/gc/tests/native_payload_lifecycle.rs b/crates/perry-runtime/src/gc/tests/native_payload_lifecycle.rs new file mode 100644 index 0000000000..02eece21c6 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/native_payload_lifecycle.rs @@ -0,0 +1,429 @@ +//! LIFECYCLE-DESIGN L4/L5/L8/L9 and runtime reopen/terminal-event contracts. +use super::*; +use np::{AttachMiss, Lifecycle}; + +fn finalized() -> usize { + crate::native_handle::PAYLOAD_FINALIZED.load(Ordering::SeqCst) +} + +#[test] +fn subclass_attachment_reopens_the_same_cell_and_rejects_open_or_finalized() { + let _guard = CopyingNurseryTestGuard::new(0); + let _reset = Reset::new(); + let scope = RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(crate::object::js_object_alloc(777, 0)); + let value = || { + obj.with_mut_ptr::(|obj| { + crate::value::js_nanbox_pointer(obj as i64) + }) + }; + assert!(np::attach_to_object(value(), &FAMILY, Probe::default(), 0)); + let cell_ptr = obj.with_mut_ptr::(|obj| unsafe { + ((*(*obj).meta).native_state & POINTER_MASK) + as *mut crate::native_handle::NativeHandleHeader + }); + assert!(!np::attach_to_object(value(), &FAMILY, Probe::default(), 0)); + assert_eq!(DROPS.load(Ordering::SeqCst), 1, "rejected input is dropped"); + assert!(np::close_attached::(value(), &FAMILY)); + assert!(np::attach_to_object(value(), &FAMILY, Probe::default(), 0)); + obj.with_mut_ptr::(|obj| unsafe { + assert_eq!( + ((*(*obj).meta).native_state & POINTER_MASK) as *mut _, + cell_ptr + ); + assert_eq!((*obj).class_id, 777); + }); + unsafe { crate::native_handle::finalize_native_handle_at_teardown(cell_ptr) }; + assert!(!np::attach_to_object(value(), &FAMILY, Probe::default(), 0)); + assert_eq!(DROPS.load(Ordering::SeqCst), 4); +} + +#[test] +fn l4_release_then_unrooted_sweep_finalizes_without_another_drop() { + let _guard = CopyingNurseryTestGuard::new(0); + let _reset = Reset::new(); + let _no_stack = ConservativeScanDisabledGuard::new(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(owner()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { + np::payload_mut::(value.get_nanbox_f64(), &FAMILY) + .unwrap() + .link = Some(link) + }; + let before = finalized(); + assert_eq!( + np::close(value.get_nanbox_f64(), &FAMILY), + CloseOutcome::Closed + ); + assert_eq!( + np::lifecycle(value.get_nanbox_f64(), &FAMILY), + Ok(Lifecycle::Closed) + ); + assert_eq!(finalized(), before, "release must not finalize"); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + assert_eq!(unsafe { (*cell(link)).refs }, 0); + drop(scope); + full(); + assert_eq!(finalized(), before + 1, "an unrooted closed cycle must die"); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + assert_eq!(FINAL_JS.load(Ordering::SeqCst), 0); +} + +#[test] +fn l5_teardown_finalized_cell_cannot_attach() { + let _guard = CopyingNurseryTestGuard::new(0); + let _reset = Reset::new(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(owner()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { crate::native_handle::finalize_native_handle_at_teardown(cell(link)) }; + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + assert_eq!( + np::attach(value.get_nanbox_f64(), &FAMILY, Probe::default(), 0), + Err(AttachMiss::Finalized) + ); + assert_eq!(DROPS.load(Ordering::SeqCst), 2, "rejected input is dropped"); + assert_eq!(unsafe { np::link_event_owner(link) }, None); + assert!(unsafe { (*cell(link)).resource_ptr.is_null() }); +} + +// Same size/alignment as Probe, but a different destructor. A safe attach +// must never install this under Probe's retained drop thunk. +struct OtherProbe([usize; 2]); +impl Drop for OtherProbe { + fn drop(&mut self) { + CALLS.fetch_add(self.0[0], Ordering::SeqCst); + } +} + +#[test] +fn reopen_preserves_object_cell_properties_and_serial_identity() { + let _guard = CopyingNurseryTestGuard::new(1); + let _reset = Reset::new(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(np::alloc_closed(&FAMILY, &[(b"own", 123.0)])); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + let before = value.get_nanbox_f64().to_bits(); + let proto = unsafe { + crate::object::shapes::object_prototype_word( + (before & POINTER_MASK) as *const crate::object::ObjectHeader, + ) + }; + let shape = unsafe { + crate::object::shapes::object_shape_stamp( + (before & POINTER_MASK) as *const crate::object::ObjectHeader, + ) + }; + let bytes = policy::external_side_live_bytes(); + assert_eq!( + np::lifecycle(value.get_nanbox_f64(), &FAMILY), + Ok(Lifecycle::Closed) + ); + assert!(matches!( + np::enter(value.get_nanbox_f64(), &FAMILY), + Err(PayloadMiss::Closed) + )); + let mut serial = np::next_open_serial(); + for _ in 0..1000 { + assert_eq!( + np::attach( + value.get_nanbox_f64(), + &FAMILY, + Probe { link: Some(link) }, + 4096 + ), + Ok(()) + ); + assert_eq!(policy::external_side_live_bytes(), bytes + 4096); + assert_eq!(np::owner_link(value.get_nanbox_f64(), &FAMILY), Ok(link)); + assert_eq!(value.get_nanbox_f64().to_bits(), before); + assert_eq!( + np::lifecycle(value.get_nanbox_f64(), &FAMILY), + Ok(Lifecycle::Open) + ); + assert_eq!( + np::attach(value.get_nanbox_f64(), &FAMILY, Probe::default(), 0), + Err(AttachMiss::Open) + ); + let old = serial; + serial = np::next_open_serial(); + assert_ne!( + old, serial, + "children of a prior open have a distinct stamp" + ); + assert_eq!( + np::close(value.get_nanbox_f64(), &FAMILY), + CloseOutcome::Closed + ); + assert_eq!(policy::external_side_live_bytes(), bytes); + assert_eq!( + np::close(value.get_nanbox_f64(), &FAMILY), + CloseOutcome::AlreadyClosed + ); + } + let obj = + (value.get_nanbox_f64().to_bits() & POINTER_MASK) as *const crate::object::ObjectHeader; + unsafe { + assert_eq!(crate::object::shapes::object_prototype_word(obj), proto); + assert_eq!(crate::object::shapes::object_shape_stamp(obj), shape); + assert_eq!( + crate::object::js_object_get_field(obj, 0).bits(), + 123.0f64.to_bits() + ); + } + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + ); + assert_eq!( + std::mem::align_of::(), + std::mem::align_of::() + ); + assert_eq!( + np::attach(value.get_nanbox_f64(), &FAMILY, OtherProbe([1, 0]), 0), + Err(AttachMiss::Foreign) + ); + assert_eq!( + CALLS.load(Ordering::SeqCst), + 1, + "rejected payload uses its own destructor" + ); + let trace = collect_minor_trace(GcTriggerKind::MallocCount); + assert!(trace.copying_nursery.eligible); + assert_ne!(before, value.get_nanbox_f64().to_bits()); + assert_eq!( + unsafe { np::link_event_owner(link) }.map(f64::to_bits), + Some(value.get_nanbox_f64().to_bits()) + ); + assert_eq!( + np::attach(value.get_nanbox_f64(), &FAMILY, Probe::default(), 0), + Ok(()) + ); + let outer = np::enter(value.get_nanbox_f64(), &FAMILY).unwrap(); + assert_eq!( + np::close(value.get_nanbox_f64(), &FAMILY), + CloseOutcome::Deferred + ); + assert_eq!( + np::lifecycle(value.get_nanbox_f64(), &FAMILY), + Ok(Lifecycle::Closing) + ); + assert_eq!( + np::attach(value.get_nanbox_f64(), &FAMILY, Probe::default(), 0), + Err(AttachMiss::Closing) + ); + assert_eq!(outer.finish(), Err(CallEnd::Closed)); + assert_eq!(unsafe { (*cell(link)).finalized }, 0); + assert_eq!(FINAL_JS.load(Ordering::SeqCst), 0); +} + +#[test] +fn terminal_item_keeps_closed_owner_through_moves_and_reads_late_listener() { + let _guard = CopyingNurseryTestGuard::new(0); + let _reset = Reset::new(); + let _no_stack = ConservativeScanDisabledGuard::new(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(owner()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { np::link_ref(link) }; // queue owns exactly one ref + assert_eq!( + np::close(value.get_nanbox_f64(), &FAMILY), + CloseOutcome::Closed + ); + assert_eq!(unsafe { np::link_owner(link) }, None); + np::set_callback( + value.get_nanbox_f64(), + &FAMILY, + 0, + closure(crate::fn_info!(returns, 0)), + ); + let before = value.get_nanbox_f64().to_bits(); + drop(scope); + let trace = collect_minor_trace(GcTriggerKind::MallocCount); + assert!(trace.copying_nursery.eligible); + let moved = unsafe { np::link_event_owner(link) }.unwrap(); + assert_ne!(before, moved.to_bits(), "closed owner must be rewritten"); + full(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(unsafe { np::link_event_owner(link) }.unwrap()); + let callbacks = np::callbacks(value.get_nanbox_f64(), &FAMILY); + let cb = crate::array::js_array_get_f64( + (callbacks.to_bits() & POINTER_MASK) as *const crate::array::ArrayHeader, + 0, + ); + // A pump runs JS directly, outside C frames; call_from_native is OPEN-only. + let out = unsafe { + crate::closure::native_call_value_this( + cb, + crate::closure::JsThis::from_f64(value.get_nanbox_f64()), + std::ptr::null(), + 0, + ) + }; + assert_eq!(out, 19.0); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); + unsafe { np::link_unref(link) }; + drop(scope); + let before = finalized(); + full(); + assert_eq!(finalized(), before + 1); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); +} + +#[test] +fn l8_worker_discards_queued_items_before_finalization_without_dispatch() { + let _guard = CopyingNurseryTestGuard::new(0); + let _reset = Reset::new(); + let before = finalized(); + let attempts = std::thread::spawn(|| { + let value = owner(); + let link = np::owner_link(value, &FAMILY).unwrap(); + unsafe { np::link_ref(link) }; + let mut queue = vec![link]; // plain queued data; Drop never dereferences a link + assert_eq!(np::close(value, &FAMILY), CloseOutcome::Closed); + let mut dispatches = 0; + if np::lifecycle_sabotage("teardown_drain") { + unsafe { crate::native_handle::finalize_native_handle_at_teardown(cell(link)) }; + // Deliberately enter the pump after teardown. The witness must + // catch even a dispatch attempt, before it can touch freed memory. + for item in queue.drain(..) { + dispatches += 1; + let _ = unsafe { np::link_event_owner(item) }; + } + } else { + queue.clear(); + } + // TLS heap teardown finalizes even though the queue's ref pin remains. + dispatches + }) + .join() + .unwrap(); + assert_eq!(attempts, 0, "teardown must discard rather than dispatch"); + assert_eq!( + finalized(), + before + 1, + "a pending ref cannot leak the worker cell" + ); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); +} + +extern "C" fn closes_then_throws( + _: *const crate::closure::ClosureHeader, + this: crate::closure::JsThis, + err: f64, +) -> f64 { + assert_eq!(np::close(this.as_f64(), &FAMILY), CloseOutcome::Deferred); + crate::exception::js_throw(err) +} +#[test] +fn l9_callback_throw_wins_over_deferred_close() { + let _guard = CopyingNurseryTestGuard::new(0); + let _reset = Reset::new(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(owner()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + let cb = closure(crate::fn_info!(closes_then_throws, 1)); + let guard = np::enter(value.get_nanbox_f64(), &FAMILY).unwrap(); + assert_eq!( + unsafe { + np::call_from_native(value.get_nanbox_f64(), cb, value.get_nanbox_f64(), &[47.0]) + }, + Err(()) + ); + assert_eq!(guard.finish(), Err(CallEnd::Threw(47.0))); + assert_eq!( + np::lifecycle(value.get_nanbox_f64(), &FAMILY), + Ok(Lifecycle::Closed) + ); + assert_eq!(unsafe { (*cell(link)).busy }, 0); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + assert_eq!( + np::attach(value.get_nanbox_f64(), &FAMILY, Probe::default(), 0), + Ok(()) + ); +} + +#[test] +fn two_hundred_thousand_released_cells_have_flat_rss_and_exact_counts() { + let _guard = CopyingNurseryTestGuard::new(0); + let _reset = Reset::new(); + let _no_stack = ConservativeScanDisabledGuard::new(); + let before = finalized(); + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let mut rss: Vec = Vec::new(); + for batch in 0..20 { + for _ in 0..10_000 { + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(np::alloc_closed(&FAMILY, &[])); + np::attach(value.get_nanbox_f64(), &FAMILY, Probe::default(), 0).unwrap(); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { np::link_ref(link) }; // pending terminal item + np::close(value.get_nanbox_f64(), &FAMILY); + unsafe { np::link_unref(link) }; // dispatched + } + // Exercise the production generational path as well as full sweep. + // Full-only collections protect the recent nursery block window, + // so untouched bump pages can inflate RSS without retaining a cell. + let trace = collect_minor_trace(GcTriggerKind::MallocCount); + assert!(trace.copying_nursery.eligible); + full(); + assert_eq!(finalized() - before, (batch + 1) * 10_000); + assert_eq!(DROPS.load(Ordering::SeqCst), (batch + 1) * 10_000); + #[cfg(target_os = "linux")] + { + let stat = std::fs::read_to_string("/proc/self/statm").unwrap(); + let pages: usize = stat.split_whitespace().nth(1).unwrap().parse().unwrap(); + rss.push(pages * 4096); + eprintln!("lifecycle churn batch {} rss={}", batch + 1, pages * 4096); + } + } + if rss.len() == 20 { + let warm = *rss[4..].iter().min().unwrap(); + let peak = *rss[4..].iter().max().unwrap(); + eprintln!("lifecycle churn: created=200000 finalized={} drops={} warm_rss={warm} peak_rss={peak} delta={}", finalized() - before, DROPS.load(Ordering::SeqCst), peak - warm); + assert!( + peak - warm < 4 * 1024 * 1024, + "RSS must stay within 4 MiB after warmup" + ); + } +} + +#[test] +fn every_lifecycle_sabotage_makes_its_witness_red() { + for (fault, witness) in [ + ( + "close_pin", + "l4_release_then_unrooted_sweep_finalizes_without_another_drop", + ), + ( + "attach_finalized", + "l5_teardown_finalized_cell_cannot_attach", + ), + ( + "teardown_drain", + "l8_worker_discards_queued_items_before_finalization_without_dispatch", + ), + ( + "closed_priority", + "l9_callback_throw_wins_over_deferred_close", + ), + ] { + let name = format!("gc::tests::native_payload_callbacks::lifecycle::{witness}"); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &name, "--nocapture", "--test-threads=1"]) + .env("PERRY_TEST_LIFECYCLE_SABOTAGE", fault) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&output.stdout).contains("running 1 test")); + assert!( + !output.status.success(), + "{fault} must make {witness} RED: {:?}", + output + ); + eprintln!("lifecycle sabotage {fault}: RED ({})", output.status); + } +} diff --git a/crates/perry-runtime/src/native_handle.rs b/crates/perry-runtime/src/native_handle.rs index fa53a73cac..a9b7a17afb 100644 --- a/crates/perry-runtime/src/native_handle.rs +++ b/crates/perry-runtime/src/native_handle.rs @@ -22,6 +22,10 @@ const THREAD_ANY: u8 = 0; const THREAD_MAIN: u8 = 1; const THREAD_CREATOR: u8 = 2; +#[cfg(test)] +pub(crate) static PAYLOAD_FINALIZED: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + static MAIN_THREAD_ID: AtomicU64 = AtomicU64::new(0); type NativeHandleFinalizer = unsafe extern "C" fn(*mut c_void, *mut c_void); @@ -59,7 +63,7 @@ pub struct NativeHandleHeader { pub debug_name: [u8; DEBUG_NAME_CAP], /// Native bytes this cell's resource holds, reported to the collector /// through `gc_note_external_side_alloc` while the resource is live and - /// released by `finalize_once` (#11919 P0). 0 for C resources, whose size + /// released by release or `finalize_once` (#11919 P0). 0 for C resources, whose size /// the runtime cannot know. pub external_bytes: u64, } @@ -297,6 +301,8 @@ unsafe fn finalize_once_with(handle: *mut NativeHandleHeader, account: bool) -> return false; } #[cfg(test)] + PAYLOAD_FINALIZED.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] let sabotage = crate::native_payload::callback_sabotage("finalized"); #[cfg(not(test))] let sabotage = false; @@ -323,7 +329,7 @@ unsafe fn finalize_once_with(handle: *mut NativeHandleHeader, account: bool) -> /// Create an OWNED handle cell for a Rust payload (#11919 P0). /// /// `drop_thunk` is the monomorphized `Box` drop for `resource_ptr`; it is -/// the cell's finalizer, so it runs exactly once: on `dispose`, at the sweep +/// the cell's finalizer, so it runs once per install: on release, at the sweep /// that finds the cell dead, or at thread teardown, whichever comes first. /// The cell starts with no external bytes: the owner reports them with /// [`native_handle_set_external_bytes`] once the cell is reachable, because @@ -346,8 +352,11 @@ pub(crate) unsafe fn native_handle_new_rust_payload( debug_name.as_ptr(), debug_name.len() as i64, ); - crate::value::JSValue::from_bits(value.to_bits()).as_pointer::() - as *mut NativeHandleHeader + let cell = crate::value::JSValue::from_bits(value.to_bits()).as_pointer::() + as *mut NativeHandleHeader; + // alloc_closed has no resource yet, but retains its eventual drop thunk. + (*cell).finalizer = drop_thunk as *mut c_void; + cell } /// State the native bytes a live Rust payload holds (at creation, or after a @@ -358,7 +367,7 @@ pub(crate) unsafe fn native_handle_set_external_bytes( handle: *mut NativeHandleHeader, bytes: usize, ) { - if handle.is_null() || (*handle).finalized != 0 { + if handle.is_null() || (*handle).finalized != 0 || (*handle).resource_ptr.is_null() { return; } let old = (*handle).external_bytes as usize; @@ -407,16 +416,42 @@ pub(crate) unsafe fn rust_payload_ptr_on_owner_thread( (*handle).resource_ptr } -/// Finalize a Rust-payload cell now (explicit close). True when this call ran -/// the drop; false when it had already run. -pub(crate) unsafe fn native_handle_dispose_rust_payload(handle: *mut NativeHandleHeader) -> bool { - if handle.is_null() || (*handle).magic != NATIVE_HANDLE_MAGIC { +/// Release the installed Rust payload, keeping the cell and owner edge alive. +/// Destruction callbacks see CLOSING throughout the drop thunk. +/// The caller validates the creator thread and defers release while busy. +pub(crate) unsafe fn native_handle_release_rust_payload(handle: *mut NativeHandleHeader) -> bool { + if handle.is_null() + || (*handle).magic != NATIVE_HANDLE_MAGIC + || (*handle).finalized != 0 + || (*handle).resource_ptr.is_null() + { return false; } - if (*handle).finalized == 0 && (*handle).creator_thread_id != current_thread_id() { - throw_type_error("Native handle used from the wrong thread"); + (*handle).flags |= crate::native_payload::CLOSING; + if (*handle).ownership == OWNERSHIP_OWNED && !(*handle).finalizer.is_null() { + let finalizer: NativeHandleFinalizer = std::mem::transmute((*handle).finalizer); + finalizer((*handle).resource_ptr, ptr::null_mut()); + } + (*handle).resource_ptr = ptr::null_mut(); + (*handle).ownership = OWNERSHIP_NULL; + let bytes = std::mem::take(&mut (*handle).external_bytes); + if bytes != 0 { + crate::gc::gc_note_external_side_free(bytes as usize); } - finalize_once(handle) + (*handle).flags &= !crate::native_payload::CLOSING; + true +} + +/// Install into a validated CLOSED cell. No allocation or owner store. +pub(crate) unsafe fn native_handle_attach_rust_payload( + handle: *mut NativeHandleHeader, + resource: *mut c_void, +) { + debug_assert_eq!((*handle).finalized, 0); + debug_assert!((*handle).resource_ptr.is_null()); + debug_assert_eq!((*handle).flags & crate::native_payload::CLOSING, 0); + (*handle).resource_ptr = resource; + (*handle).ownership = OWNERSHIP_OWNED; } /// Thread teardown: run a dying thread's handle finalizers without touching diff --git a/crates/perry-runtime/src/native_payload.rs b/crates/perry-runtime/src/native_payload.rs index cac08f3279..32f2de35a5 100644 --- a/crates/perry-runtime/src/native_payload.rs +++ b/crates/perry-runtime/src/native_payload.rs @@ -16,9 +16,10 @@ //! as long as the object and is finalized by the sweep that finds both dead. //! //! Lifetime. The cell's finalizer is the monomorphized drop of `Box`; it -//! runs exactly once, at whichever comes first of [`close`] (the family's -//! explicit close/final/digest), the sweep that finds the cell dead, or the -//! owning thread's teardown. After it runs the object stays a valid object: +//! runs once per installed payload, at release ([`close`]), sweep or thread +//! teardown. Close leaves a CLOSED cell and its traced owner edge alive; +//! only sweep and teardown finalize it. [`attach`] reopens the same cell. +//! After close the object stays a valid object: //! [`payload_mut`] answers [`PayloadMiss::Closed`] and the family reports its //! node-shaped "already finalized" error. //! @@ -45,7 +46,7 @@ //! The per-family conversion checklist is `docs/native-payload-pattern.md`. use std::ffi::c_void; -use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use crate::native_handle::NativeHandleHeader; use crate::object::ObjectHeader; @@ -341,7 +342,7 @@ pub(crate) fn is_payload_state_word(word: u64) -> bool { /// /// `external_bytes` is the native memory the payload really retains (heap /// buffers it owns, not `size_of::()` alone unless that is all it holds); -/// it feeds GC pacing until the payload is finalized. `own` lists node's own +/// it feeds GC pacing until the payload is released. `own` lists node's own /// enumerable data properties in node's order (`[("_options", undefined)]` /// for a `Hash`); values may be heap values, they are rooted here. pub fn alloc( @@ -351,19 +352,34 @@ pub fn alloc( own: &[(&[u8], f64)], ) -> f64 { let proto = family_prototype(family); - alloc_with_prototype(family, payload, external_bytes, own, proto) + alloc_cell(family, Some(payload), external_bytes, own, proto) +} + +/// Allocate an ordinary instance with its permanent cell but no resource. +/// The first attach establishes the family's payload layout and drop thunk. +pub fn alloc_closed(family: &'static NativePayloadFamily, own: &[(&[u8], f64)]) -> f64 { + let proto = family_prototype(family); + alloc_cell::<()>(family, None, 0, own, proto) } /// Allocate a payload-family instance linked to an already-materialized -/// constructor prototype. AsyncLocalStorage and AsyncResource predate the -/// shared payload-prototype cache and their bound exports already own the -/// canonical per-realm prototype used by source-compiled subclasses. +/// constructor prototype, including AsyncLocalStorage and AsyncResource. pub fn alloc_with_prototype( family: &'static NativePayloadFamily, payload: T, external_bytes: usize, own: &[(&[u8], f64)], proto: *mut ObjectHeader, +) -> f64 { + alloc_cell(family, Some(payload), external_bytes, own, proto) +} + +fn alloc_cell( + family: &'static NativePayloadFamily, + payload: Option, + external_bytes: usize, + own: &[(&[u8], f64)], + proto: *mut ObjectHeader, ) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let own_roots: Vec<_> = own @@ -389,7 +405,7 @@ pub fn alloc_with_prototype( /// half of the pattern: a source-compiled subclass keeps its own class id and /// prototype, while its traced `native_state` owns the same typed cell as a /// direct instance. -pub fn attach( +pub fn attach_to_object( value: f64, family: &'static NativePayloadFamily, payload: T, @@ -400,14 +416,31 @@ pub fn attach( }; let scope = crate::gc::RuntimeHandleScope::new(); let obj = scope.root_raw_mut_ptr(obj); - attach_rooted(&obj, family, payload, external_bytes); + let meta = obj + .with_mut_ptr::(|obj| unsafe { crate::object::object_meta_ensure(obj) }); + if meta.is_null() { + return false; + } + let previous = unsafe { (*meta).native_state }; + if is_payload_state_word(previous) { + let cell = (previous & crate::value::POINTER_MASK) as *mut NativeHandleHeader; + return attach_cell( + obj.with_mut_ptr::(|obj| crate::value::js_nanbox_pointer(obj as i64)), + cell, + family, + payload, + external_bytes, + ) + .is_ok(); + } + attach_rooted(&obj, family, Some(payload), external_bytes); true } fn attach_rooted( obj: &crate::gc::RuntimeHandle<'_>, family: &'static NativePayloadFamily, - payload: T, + payload: Option, external_bytes: usize, ) { let meta = obj @@ -415,18 +448,18 @@ fn attach_rooted( if meta.is_null() { return; } - let previous = unsafe { (*meta).native_state }; - if is_payload_state_word(previous) { - let cell = (previous & crate::value::POINTER_MASK) as *mut NativeHandleHeader; - if unsafe { (*cell).type_id } == type_tag::(family.class_id) { - unsafe { crate::native_handle::native_handle_dispose_rust_payload(cell) }; - } - } - let resource = Box::into_raw(Box::new(payload)) as *mut c_void; + // The cell allocation may collect; re-read meta through the rooted object. + let resource = payload.map_or(std::ptr::null_mut(), |p| { + Box::into_raw(Box::new(p)) as *mut c_void + }); let cell = unsafe { crate::native_handle::native_handle_new_rust_payload( resource, - type_tag::(family.class_id), + if resource.is_null() { + family.class_id as u64 + } else { + type_tag::(family.class_id) + }, drop_payload::, family.name, ) @@ -629,13 +662,118 @@ pub enum CloseOutcome { pub(crate) const PENDING: u8 = 1; pub(crate) const CLOSING: u8 = 2; +/// The non-finalized cell states. Finalized cells answer PayloadMiss::Closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lifecycle { + Open, + Closing, + Closed, +} + +pub fn lifecycle(value: f64, family: &NativePayloadFamily) -> Result { + let cell = payload_cell(value, family.class_id)?; + unsafe { + if (*cell).finalized != 0 + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + { + return Err(PayloadMiss::Closed); + } + Ok(if (*cell).flags & CLOSING != 0 { + Lifecycle::Closing + } else if (*cell).resource_ptr.is_null() { + Lifecycle::Closed + } else { + Lifecycle::Open + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttachMiss { + Foreign, + Open, + Closing, + Finalized, +} + +/// CLOSED -> OPEN in the same cell. No JS or GC allocation before install. +/// On rejection payload is dropped. Check lifecycle before opening a C resource. +/// One payload type per family, including across reopen. +pub fn attach( + value: f64, + family: &'static NativePayloadFamily, + payload: T, + external_bytes: usize, +) -> Result<(), AttachMiss> { + let cell = payload_cell(value, family.class_id).map_err(|_| AttachMiss::Foreign)?; + attach_cell(value, cell, family, payload, external_bytes) +} + +fn attach_cell( + value: f64, + cell: *mut NativeHandleHeader, + family: &'static NativePayloadFamily, + payload: T, + external_bytes: usize, +) -> Result<(), AttachMiss> { + unsafe { + if (*cell).magic != crate::native_handle::NATIVE_HANDLE_MAGIC + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + { + return Err(AttachMiss::Foreign); + } + if (*cell).finalized != 0 && !lifecycle_sabotage("attach_finalized") { + return Err(AttachMiss::Finalized); + } + if (*cell).flags & CLOSING != 0 || (*cell).busy != 0 { + return Err(AttachMiss::Closing); + } + if !(*cell).resource_ptr.is_null() { + return Err(AttachMiss::Open); + } + let tag = type_tag::(family.class_id); + if (*cell).type_id != family.class_id as u64 + && ((*cell).type_id != tag || (*cell).finalizer != drop_payload:: as *mut c_void) + { + return Err(AttachMiss::Foreign); + } + // alloc_closed cannot know T. Establish the layout/thunk on first + // attach; subsequent opens keep both unchanged. + if (*cell).type_id == family.class_id as u64 { + (*cell).type_id = tag; + (*cell).finalizer = drop_payload:: as *mut c_void; + } + let resource = Box::into_raw(Box::new(payload)) as *mut c_void; + crate::native_handle::native_handle_attach_rust_payload(cell, resource); + // Root the owner before reporting bytes: pacing can collect. The + // payload is already installed and reachable through the owner. + let scope = crate::gc::RuntimeHandleScope::new(); + let _owner = scope.root_nanbox_f64(value); + crate::native_handle::native_handle_set_external_bytes(cell, external_bytes); + } + Ok(()) +} + +/// A resource incarnation. Store it in reopenable payloads, children and data +/// completions, and compare once before using the current resource. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OpenSerial(u64); +static NEXT_OPEN_SERIAL: AtomicU64 = AtomicU64::new(1); + +pub fn next_open_serial() -> OpenSerial { + let serial = NEXT_OPEN_SERIAL.fetch_add(1, Ordering::Relaxed); + assert_ne!(serial, 0, "native open serial exhausted"); + OpenSerial(serial) +} + /// Close immediately, or defer native destruction until the outer C call ends. pub fn close(value: f64, family: &NativePayloadFamily) -> CloseOutcome { let Ok(cell) = payload_cell(value, family.class_id) else { return CloseOutcome::Foreign; }; unsafe { - if (*cell).finalized != 0 || (*cell).flags & CLOSING != 0 { + if (*cell).finalized != 0 || (*cell).flags & CLOSING != 0 || (*cell).resource_ptr.is_null() + { return CloseOutcome::AlreadyClosed; } // Preserve ordinary receiver thread validation (trampolines use link_owner). @@ -644,14 +782,18 @@ pub fn close(value: f64, family: &NativePayloadFamily) -> CloseOutcome { } #[cfg(test)] if callback_sabotage("close") { - crate::native_handle::native_handle_dispose_rust_payload(cell); + crate::native_handle::native_handle_release_rust_payload(cell); return CloseOutcome::Closed; } if (*cell).busy != 0 { (*cell).flags |= CLOSING; return CloseOutcome::Deferred; } - crate::native_handle::native_handle_dispose_rust_payload(cell); + crate::native_handle::native_handle_release_rust_payload(cell); + #[cfg(test)] + if lifecycle_sabotage("close_pin") { + link_ref(OwnerLink(cell as usize)); + } CloseOutcome::Closed } } @@ -663,14 +805,17 @@ pub fn close(value: f64, family: &NativePayloadFamily) -> CloseOutcome { #[repr(transparent)] pub struct OwnerLink(pub(crate) usize); -/// Obtain a link only for an open family with the traced owner edge enabled. +/// Obtain a link in any non-finalized state with the traced owner edge enabled. pub fn owner_link(value: f64, family: &NativePayloadFamily) -> Result { if !family.links_owner { return Err(PayloadMiss::Foreign); } let cell = payload_cell(value, family.class_id)?; unsafe { - if crate::native_handle::rust_payload_ptr_on_owner_thread(cell).is_null() { + if (*cell).magic != crate::native_handle::NATIVE_HANDLE_MAGIC + || (*cell).finalized != 0 + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + { return Err(PayloadMiss::Closed); } if (*cell).owner == 0 { @@ -700,6 +845,23 @@ pub unsafe fn link_owner(link: OwnerLink) -> Option { ((*cell).owner != 0).then(|| f64::from_bits((*cell).owner)) } +/// Owner lookup for pump events, including terminal events after release. +/// Never throws or allocates. Read listeners from JS state at dispatch time. +/// +/// # Safety +/// The cell must be alive on its creator thread, kept by one ref per queued +/// item. Drop items before worker heap teardown; never dispatch after teardown. +pub unsafe fn link_event_owner(link: OwnerLink) -> Option { + let cell = link.0 as *mut NativeHandleHeader; + if (*cell).magic != crate::native_handle::NATIVE_HANDLE_MAGIC + || (*cell).finalized != 0 + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + { + return None; + } + ((*cell).owner != 0).then(|| f64::from_bits((*cell).owner)) +} + #[repr(C)] pub struct CallbackSite { pub link: OwnerLink, @@ -734,6 +896,9 @@ pub fn enter(value: f64, family: &NativePayloadFamily) -> Result Result Result<(), f64> { + pub fn finish(self) -> Result<(), CallEnd> { unsafe { let cell = self.link.0 as *mut NativeHandleHeader; let scope = crate::gc::RuntimeHandleScope::new(); let owner = scope.root_nanbox_f64(f64::from_bits((*cell).owner)); assert_ne!((*cell).busy, 0, "unbalanced native call"); (*cell).busy -= 1; + let closing = (*cell).flags & CLOSING != 0; let result = if (*cell).flags & PENDING != 0 { let state = root_pointer::( &scope, @@ -768,12 +941,18 @@ impl NativeCallGuard { // No allocation follows this take: the existing plain slot is cleared. set_state_field(&scope, &state, b"pendingException", undefined()); (*cell).flags &= !PENDING; - Err(err.get_nanbox_f64()) + if closing && lifecycle_sabotage("closed_priority") { + Err(CallEnd::Closed) + } else { + Err(CallEnd::Threw(err.get_nanbox_f64())) + } + } else if closing { + Err(CallEnd::Closed) } else { Ok(()) }; if (*cell).busy == 0 && (*cell).flags & CLOSING != 0 { - crate::native_handle::native_handle_dispose_rust_payload(cell); + crate::native_handle::native_handle_release_rust_payload(cell); } result } @@ -1074,7 +1253,7 @@ pub fn close_attached(value: f64, family: &NativePayloadFamily) -> b } let cell = (unsafe { (*meta).native_state } & crate::value::POINTER_MASK) as *mut NativeHandleHeader; - unsafe { crate::native_handle::native_handle_dispose_rust_payload(cell) } + unsafe { crate::native_handle::native_handle_release_rust_payload(cell) } } /// Re-state the native bytes a live payload retains (after a buffer grew or @@ -1274,3 +1453,16 @@ fn pending_sabotage() -> bool { false } } + +#[inline] +pub(crate) fn lifecycle_sabotage(fault: &str) -> bool { + #[cfg(test)] + { + std::env::var("PERRY_TEST_LIFECYCLE_SABOTAGE").as_deref() == Ok(fault) + } + #[cfg(not(test))] + { + let _ = fault; + false + } +} diff --git a/crates/perry-stdlib/src/async_local_storage.rs b/crates/perry-stdlib/src/async_local_storage.rs index d86fa8f4c7..6bee12bcc9 100644 --- a/crates/perry-stdlib/src/async_local_storage.rs +++ b/crates/perry-stdlib/src/async_local_storage.rs @@ -183,7 +183,7 @@ fn ensure_async_local_storage_token(receiver: i64) -> Option { Err(PayloadMiss::Closed) => { let scope = perry_runtime::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(value); - native_payload::attach( + native_payload::attach_to_object( receiver.get_nanbox_f64(), &ASYNC_LOCAL_STORAGE_FAMILY, perry_runtime::async_context::AsyncLocalStoragePayload::default(), @@ -205,7 +205,7 @@ pub extern "C" fn js_async_local_storage_subclass_init(this_value: f64) -> f64 { let scope = perry_runtime::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(this_value); let _ = canonical_prototype(); - native_payload::attach( + native_payload::attach_to_object( receiver.get_nanbox_f64(), &ASYNC_LOCAL_STORAGE_FAMILY, perry_runtime::async_context::AsyncLocalStoragePayload::default(), diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs index cc740cbe18..8f90599a02 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -465,6 +465,12 @@ fn external_buffer_verdict_is_taken_at_release_not_by_address() { #[test] fn thread_exit_releases_the_threads_dom_exceptions() { + // Initialize the observing heap before the worker releases its blocks. + // The brand is now in the cell, not an address registry: initializing + // this heap after join can reuse the worker's block and register its + // stale bytes as our own arena (notably with alloc-mimalloc enabled). + let scope = RuntimeHandleScope::new(); + let _observer = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(0)); let (err, alive) = std::thread::spawn(|| { let err = perry_runtime::event_target::js_dom_exception_new(undefined(), undefined()) as usize; diff --git a/crates/perry-stdlib/src/streams/tests.rs b/crates/perry-stdlib/src/streams/tests.rs index 6720497777..ab2a1b1d95 100644 --- a/crates/perry-stdlib/src/streams/tests.rs +++ b/crates/perry-stdlib/src/streams/tests.rs @@ -497,6 +497,9 @@ extern "C" fn collecting_pair_getter( #[test] fn pipe_through_pair_survives_a_moving_getter() { let _serial = serial_guard(); + // Rust unit tests bypass the generated program's startup. Register the + // handle/accessor roots before the getters deliberately collect. + perry_runtime::gc::gc_init(); struct RestoreGc(i32); impl Drop for RestoreGc { fn drop(&mut self) { diff --git a/docs/native-payload-lifecycle-rss.md b/docs/native-payload-lifecycle-rss.md new file mode 100644 index 0000000000..333eb38238 --- /dev/null +++ b/docs/native-payload-lifecycle-rss.md @@ -0,0 +1,191 @@ +# Lifecycle RSS attribution on current main + +PR #12036 was rebased from `b49a915e2e9291e2e3c88fa33f1fa1f86710abbb` +onto `495fa8f94984177d945707a95ab0ef25aa4acf85`. This supersedes the older +base and measurements in `native-payload-lifecycle-validation.md`. + +The reported +4–4.9 MiB comparison used an older main/merged tree. On current +main, the initial seven-pair comparison reproduced +1,584 KiB with THP off. +There is no corresponding extra heap allocation or retained payload in this +comparison. Clean executable page residency depends on the executable's +file-cache history. Disabling anonymous THP does not control those pages. + +## Attribution + +All measurements ran on qb6 in `/root/codex-lanes/cx-lifecycle-rss`, with main +in `main/src` and `main/target`, and head in `src` and `target`. Both arms built +the four requested packages with `cargo build --release -j 12`. The compiler +drivers used Node 26.5.1, `PERRY_ALLOW_PERRY_FEATURES=1`, +`PERRY_KEEP_SYMBOLS=1`, no auto-optimization/cache, module jobs 1 and codegen +unit jobs 2. No macOS builds, version edits or pushes were performed. + +The large tsc generated objects were retained from main's complete native +build and relinked against each arm's separately built runtime/stdlib +archives. Relinking main reproduced the original **whole ELF byte for byte** +(SHA-256 `d583b52fb8aef44a9deb6e37975698a51d9f844bf86408b027116cd9f7414225`). +This controls generated code while measuring the lifecycle runtime change. +The other five drivers were compiled independently for each arm. + +| Evidence | Main | Head | +|---|---:|---:| +| THP-off peak sampled RSS before equal cache preparation, median KiB | 182,596 | 192,792 | +| Anonymous pages in those samples, median KiB | 96,272 | 96,276 | +| File PSS in those samples, median KiB | 83,691 | 93,910 | +| Anonymous pages after equal preparation, median KiB | 96,708 | 96,724 | +| File PSS after equal preparation, median KiB | 83,404 | 83,322 | +| libc `[heap]` mapping resident KiB | 12 | 12 | +| Census reachable objects / bytes | 58,800 / 4,809,632 | 58,800 / 4,809,632 | +| Native-handle cells, live or dead | 0 | 0 | +| Arena capacity / occupied bytes | 63,963,136 / 46,871,336 | 63,963,136 / 46,871,336 | +| Eden / survivor0 / survivor1 / long-lived / old blocks | 32 / 1 / 3 / 2 / 23 | 32 / 1 / 3 / 2 / 23 | +| Mimalloc committed, one iteration | 144.9 MiB | 144.9 MiB | +| Mimalloc committed, three iterations | 191.1 MiB | 191.1 MiB | +| `mi_malloc*` requested bytes / calls | 113,925,585 / 2,325,609 | 113,925,631 / 2,325,611 | +| `mi_realloc*` requested bytes / calls | 35,213,491 / 163,871 | 35,212,723 / 163,870 | + +Peak residency was sampled from `smaps_rollup` every 10 ms, retaining full +`smaps` at each higher sampled RSS. These sampled peaks and PSS values are +separate observations from `/usr/bin/time` maximum RSS; PSS also depends on +other processes sharing file pages. All THP-off samples had zero +`AnonHugePages`. Most anonymous memory belongs to mimalloc mappings, rather +than libc's small `[heap]` mapping. + +The census is an explicitly requested full collection at the generated +program's `js_process_emit_before_exit_pending` call, after workload output; +it is an end-of-workload reachability observation, not a peak-RSS census. +Both GDB inferiors exited normally. An earlier console-entry breakpoint +experiment invalidated unrooted console arguments when forcing GC and is +excluded. Natural pressure diagnostics match arena sizes, promotions and +old-generation baseline; budgeted full cycles do not emit this synchronous +census. + +All live type counts and bytes match: arrays 1,211,088; objects 332,840; +strings 1,641,152; closures 1,413,904; maps 6,960; lazy arrays 176; sets 560; +dates 48; object metadata 88,032; regexps 3,776; regex programs 43,208; +boxes 496; scopes 67,392. No per-object serial storage, larger cell or +per-thread allocation explains the gap. Cell size remains 136 bytes; +`GcTypeInfo` and the payload lookup hot paths have no layout/logic change. + +The bpftrace probes matched `mi_malloc*` and `mi_realloc*`, with 12-frame +native stacks for requests of at least 1 KiB. Every request-size count at +1 KiB and above matches. Symbolized call-site differences merely redistribute +GC worklist/arena growth between scanner callers, with equal totals; the +combined requested-byte difference is −722 bytes. These traces compare the +initial rebased arm, before subclass integration corrections. Neither +correction is called by tsc. Raw traces and symbolized summaries are retained +with the delivered measurement evidence. + +## Identical-binary control and measurement correction + +Copying main's tsc ELF to a new file preserves the SHA-256 above but changes +its cache history. Seven interleaved pairs showed the following median RSS: + +| Identical main ELF, KiB | Original file | Copied file | Difference | +|---|---:|---:|---:| +| Existing cache, normal THP policy | 221,724 | 232,868 | +11,144 | +| Existing cache, THP off | 181,944 | 192,544 | +10,600 | +| Equal cache preparation, normal THP policy | 221,960 | 221,272 | −688 | +| Equal cache preparation, THP off | 182,168 | 182,420 | +252 | + +This is a cache-state effect on mapped executable pages. Reading each file +alone did not remove it; discarding its previous cached extents and reading +it identically did. No global `drop_caches` or system THP setting was changed. + +`scripts/runtime_rss_ab.py` fsyncs each executable, applies +`POSIX_FADV_DONTNEED` only to that file, then reads it in 1 MiB chunks before +alternating arms. It records every sample and min/median/max and can check +against saved Node output. Its `--cache existing` mode supports the control. +THP off uses per-process `PR_SET_THP_DISABLE`; it does not alter the host. +The correction is in the measurement protocol. No speculative runtime +allocation optimization or allocator tuning was added to conceal the gap. + +## Rebase integration + +Current main added `alloc_with_prototype` and existing-object attachment for +ALS/AsyncResource. The rebase retains those constructors and gives the +existing-object operation the name `attach_to_object`, separating it from +the lifecycle operation `attach`. It roots subclass owners, reuses CLOSED +cells, rejects OPEN/CLOSING/finalized/incompatible cells and drops rejected +inputs. A new witness checks same-cell identity, the subclass's class id, +input destruction and finalized rejection. + +AsyncHook already has an OPEN payload with an unpublished index at +`createHook`. Publishing the record must update that payload in place; +trying to replace it through attach correctly rejects OPEN and dropping the +rejected input retires the new record. The integration now initializes that +index in place, while a released hook reattaches through its CLOSED cell. +Existing moving-callback and worker-exit witnesses cover this case. + +## Final verification + +The final release build passed. tsc's text/data/bss sizes in bytes are +135,257,556 / 3,816,888 / 1,741,488 on main and +135,259,532 / 3,816,912 / 1,741,488 on head: +1,976 text bytes, +24 data +bytes and unchanged bss, far below the reported RSS difference. + +Seven alternating pairs per mode, with equal executable-cache preparation, +give the following maximum-RSS distributions. The one-iteration rows are +the final repeat after our builds and tests stopped; the three-iteration +rows are the preceding final-build verification. Every output equals Node. + +| tsc iterations / THP mode | Main min / median / max KiB | Head min / median / max KiB | Difference of medians KiB | +|---|---:|---:|---:| +| 1 / normal | 221,300 / 223,832 / 224,332 | 223,144 / 223,812 / 224,372 | −20 | +| 1 / off | 182,240 / 182,616 / 183,188 | 181,480 / 182,460 / 183,432 | −156 | +| 3 / normal | 266,420 / 268,556 / 269,476 | 266,292 / 268,712 / 269,316 | +156 | +| 3 / off | 223,296 / 223,544 / 224,232 | 222,808 / 223,496 / 223,972 | −48 | + +All median differences and differences between the maxima in these final +distributions are within +0.5 MiB. The final one-iteration paired medians +are −144 KiB normally and −448 KiB with THP off. Variability remains: +the normal-mode mean difference is +579 KiB because three main samples +were about 2 MiB below its median. The preceding one-iteration verification +had mean differences +141 / +243 KiB and paired medians +320 / +316 KiB +(normal / THP off), but its THP-off difference of arm medians was +688 KiB. +These complete distributions are retained, rather than discarded when a +particular summary crosses a threshold. The old separation in which every +head sample exceeded every main sample is absent. + +The final instruction check uses three alternating pairs per driver and +`instructions:u`; it includes the `/usr/bin/time` wrapper in both arms. + +| Program | Main median instructions | Head median instructions | Difference | +|---|---:|---:|---:| +| tsc, one iteration | 10,193,434,762 | 10,193,275,524 | −0.0016% | +| Zod, 5,000 iterations | 16,117,308,383 | 16,115,492,209 | −0.0113% | +| qs parse | 28,552,609,568 | 28,559,870,464 | +0.0254% | +| qs stringify | 76,437,470,565 | 76,434,820,801 | −0.0035% | +| commander | 7,811,421,068 | 7,812,076,791 | +0.0084% | +| hello | 1,382,077 | 1,381,632 | −0.0322% | + +`cargo test --release -j 12 --no-fail-fast -p perry-runtime -p perry-stdlib +-p perry-codegen -- --nocapture` completed with the following results: + +| Package | Unit passed | Integration passed | Doc passed | Failed | Ignored | +|---|---:|---:|---:|---:|---:| +| runtime | 5,060 | 1 | 0 | 0 | 13 | +| stdlib | 247 | 0 | 0 | 0 | 0 | +| codegen | 1,996 | 501 | 3 | 15 | 6 | + +Every T1–T12 and L4/L5/L8/L9 witness passes, and all 14 callback and four +lifecycle sabotages are RED. Same-cell subclass reopen and the existing +moving-callback / worker-exit hook witnesses pass. Cell layout remains +136 bytes and lifecycle churn retains its <4 MiB assertion and exact counts. + +The requested all-zero codegen gate is **unfinished**: exactly the same +15 `native_proof_buffer_views` artifact assertions fail on untouched +`495fa8f949`. That baseline run has 32 passed / 15 failed. No codegen, +HIR, parser, manifest or lockfile source was changed by this lane; all other +codegen targets pass. These unrelated failures were not suppressed or +re-baselined to claim a green result. + +Fmt, whitespace, Node-version consistency, native-handle ledger and GC +root-holder checks pass; root-holder self-test covers 92 planted shapes. +Inherited gates remain: `object/mod.rs` has 2,001 lines on both main and +head; raw-handle debt is identical at 869 sites and five module violations; +native-handle ledger self-test has the same stale ext-zlib classification. +No ceilings or inventories were raised. + +All original comparisons, diagnostic replays, allocation traces, controls, +test logs and final verification samples are retained in the local +`lifecycle-rss/evidence` directory beside `lifecycle-rss.bundle`. diff --git a/docs/native-payload-lifecycle-validation.md b/docs/native-payload-lifecycle-validation.md new file mode 100644 index 0000000000..60c26907e1 --- /dev/null +++ b/docs/native-payload-lifecycle-validation.md @@ -0,0 +1,159 @@ +# Native payload lifecycle runtime validation (#11919) + +Current-main rebase integration and RSS attribution are recorded in +[native-payload-lifecycle-rss.md](native-payload-lifecycle-rss.md). The results +below describe the earlier base and its original measurement protocol. + +Lane base: `51a20469efd7bf418e3a52b9b6aa887eef4f2290`. Final verification and +A/B code base: `014f3e553d574e9e136b57b8ac57c89519755459`. Final head includes current +main `a6c147b7b00b51f18f56ce2e056cf190deca893d`; the two intervening commits +change only root-holder inventory and changelog metadata. Builds and tests run on +`perrymaster` in `/root/codex-lanes/cx-lifecycle`, with separate `target` and +`main-target` directories, Node 26.5.1 and `RUST_TEST_THREADS=1`. No builds ran +on macOS. The exact raw results are also delivered beside `lifecycle.bundle`. + +## API and invariants + +| API | Behavior | +|---|---| +| `alloc_closed(family, own)` | Installs the object's permanent cell without a native resource | +| `lifecycle(value, family)` | `Open`, `Closing`, `Closed`; finalized is `PayloadMiss::Closed` | +| `attach(value, family, payload, bytes)` | Installs into that same CLOSED cell; rejects `Foreign`, `Open`, `Closing`, `Finalized` and drops the rejected input | +| `next_open_serial()` | One process-wide atomic supplies opaque `OpenSerial` equality stamps for reopenable payloads, children and resource completions | +| `close(value, family)` | Releases, preserving the cell, owner edge, type/drop metadata, refs and creator thread; busy calls defer release | +| `owner_link(value, family)` | Accepts every non-finalized state for owner-linked families | +| `link_owner(link)` | Existing OPEN-only, non-throwing synchronous trampoline path, unchanged | +| `link_event_owner(link)` | Non-throwing OPEN/CLOSING/CLOSED owner lookup for event dispatch | +| `enter(value, family)` | Independently requires OPEN before incrementing busy | +| `NativeCallGuard::finish()` | `Result<(), CallEnd>`; `Threw(value)` wins over `Closed`; outermost close releases after C returns | +| `link_ref` / `link_unref` | Unchanged; a queued item owns one ref through dispatch, even after close | + +Release sets CLOSING around the drop thunk, nulls the resource, clears +ownership, returns external bytes and clears CLOSING. Sweep and teardown alone +finalize Rust payload cells. `payload_mut`, `link_owner`, the owner/metadata +stores and cell layout are unchanged (136 bytes on 64-bit). The GC visitor +continues visiting CLOSED owner edges. Teardown finalizes pinned native cells; +other pinned objects retain their existing teardown behavior. + +The requested non-generic `alloc_closed` cannot know `T`. Its first attach +therefore establishes the existing type-layout tag and drop thunk. Subsequent +attaches preserve them; a different layout or drop thunk is rejected as +Foreign, including a distinct payload type with the same size and alignment. This is the +only additional first-install metadata write, and avoids changing the family +API, growing the cell or changing payload access. Reporting external bytes +can collect **after** installation, so attach roots the owner for that report. +There is no GC allocation or JS call before installation. + +PR #12020 / `attach_rooted` was absent from the base; no ALS call site was +available to replace. Family-specific listener queues, sqlite child serial +checks and callback-array clearing remain the family lanes' responsibility. +The L8 runtime witness models the plain-data pump queue and exercises actual +worker TLS cleanup with a still-pinned cell; it does not convert a family's +queue in this runtime lane. + +## Witnesses and sabotages + +All sabotage arms exist only in test binaries and run their exact witness in +an isolated child. The harness asserts one test ran and that it failed. + +| Design test | Runtime witness | Sabotage | +|---|---|---| +| T1 | Owner relocates while a native call/site is live | Omit owner rewrite | +| T2 | Ref'ed cell preserves owner/callback through full GC | Omit owner mark; omit pin | +| T3 | Malloc-cell owner store enters the remembered set | Omit barrier | +| T4/T5 | Exact throw identity, C regains control, reuse, first throw wins | Omit catch; omit pending short circuit | +| T4 validation | Pending TypeError is parked without throwing through C | Covered by pending/throw protocol | +| T6/T11 | No destruction callback enters JS at close, sweep or worker exit | Omit finalized lookup; finalize after drop | +| T7/T8 | Nested calls, immediate closed visibility, deferred release | Release while busy; reject reentry | +| T9 | Wrong-thread owner lookup never throws | Use throwing lookup | +| T10 | 200,000 owner capture cycles collect | Leak a ref | +| T12 | Nested catch/rethrow leaves busy and try depth balanced | Throw from conversion before finish | +| Pin cost | Cell refs do not arm young-pin latch | Pin owner | +| L4 | Release then unrooted sweep: finalized +1, drops unchanged, no JS | Leave close ref/pin | +| L5 | Teardown-finalized cell rejects attach and drops input | Ignore finalized at attach | +| L8 | Worker discards queue without dispatch; pending pin cannot leak cell | Dispatch queue after finalize | +| L9 | Callback closes then throws; exact throw wins, reopen works | Prefer Closed over Threw | +| Reopen identity | 1,000 attaches preserve object/cell/properties/prototype/links and return bytes; reopen after moving GC; reject Open/Closing | State/identity assertions | +| Terminal events | Closed owner survives full + moving GC; dispatch reads late listener; last unref collects it | State/trace assertions | +| Lifecycle churn | 200,000 alloc_closed/attach/ref/close/unref cycles; created = finalized = drops; RSS delta <4 MiB | Exact counts and RSS bound | + +The callback suite retains all 14 original sabotage pairs. The lifecycle +suite adds four pairs for L4/L5/L8/L9. These are runtime-contract units; the +sqlite/net behavioral and Node-oracle witnesses remain their family lanes. + +## Recorded results + +`cargo build --release -j 8 -p perry -p perry-runtime -p perry-runtime-static +-p perry-stdlib-static` passed for each arm. Final combined validation: +`cargo test --release -j 8 -p perry-runtime -p perry-stdlib -p perry-codegen +-- --nocapture` exited 0. + +| Package | Unit | Integration | Doc | Failed | Ignored | +|---|---:|---:|---:|---:|---:| +| runtime | 5,019 | 1 | 0 | 0 | 13 | +| stdlib | 247 | 0 | 0 | 0 | 0 | +| codegen | 1,991 | 516 | 3 | 0 | 6 | +| Total | 7,257 | 517 | 3 | 0 | 19 | + +Every T1–T12 witness passed; all 14 callback sabotages were RED. L4, L5, +L8, L9, reopen identity, terminal-event tracing/late listener and churn passed; +all four lifecycle sabotages were RED. The isolated lifecycle churn reported +`created=finalized=drops=200000`, warmed RSS 45,883,392 bytes, peak 45,883,392, +delta **0 bytes**. In the complete runtime process its warmed delta was +24,576 bytes. The <4 MiB assertion is unchanged. Each batch runs a moving +minor and full sweep: a full-only schedule protects recent nursery bump +blocks and initially exceeded the RSS bound despite exact finalized/drop +counts; the witness now exercises the production generational path. + +Program measurements use three alternating samples per arm, `instructions:u` +from `perf stat`, task-clock converted from nanoseconds to milliseconds, +`/usr/bin/time` peak RSS and separate GC diagnostic replays. Every sample +compares stdout byte for byte with pinned Node 26.5.1. Results below are +medians; GC columns are full/minor counts, identical between arms. + +| Program | Instructions main → head | Δ instructions | CPU ms main → head | RSS KiB main → head | GC full/minor | Node output | +|---|---:|---:|---:|---:|---:|---| +| hello | 1,338,162 → 1,338,237 | +0.0056% | 1.91 → 2.13 | 14,680 → 14,328 | 0/0 | equal | +| tsc | 28,263,596,386 → 28,262,329,615 | -0.0045% | 2,086.65 → 2,067.22 | 258,220 → 261,724 | 1/4 | equal | +| zod5k | 16,054,077,702 → 16,053,836,420 | -0.0015% | 931.96 → 935.72 | 55,828 → 56,096 | 0/97 | equal | +| qsparse | 28,620,443,360 → 28,621,744,435 | +0.0045% | 1,886.07 → 1,882.21 | 58,876 → 58,900 | 0/119 | equal | +| qsstr | 76,491,515,715 → 76,447,325,656 | -0.0578% | 4,376.35 → 5,053.81 | 58,508 → 58,660 | 0/439 | equal | +| commander | 7,797,884,533 → 7,797,798,652 | -0.0011% | 571.04 → 574.23 | 51,572 → 51,892 | 0/33 | equal | + +The largest median RSS difference is tsc +3,504 KiB (+1.36%); the other +programs differ by −352 to +320 KiB. Instruction changes are all within +±0.058%. CPU timings varied between passes on the shared host (qs stringify +was 5,228.78 → 5,085.54 ms in the first pass, 4,376.35 → 5,053.81 ms in the +final pass); the requested instruction gate passes in both passes. + +| Gate | Final head / main comparison | +|---|---| +| native_handle_ledger | PASS: 202 tables / 188 producers; ceilings unchanged | +| native_handle_ledger self-test | Same inherited failure: stale ext-zlib `__STATICS_HANDLE_TABLES` classification | +| raw_handle_debt | Same 868 sites vs baseline 861; three inherited module violations, no new sites | +| raw_handle_debt self-test / no-raise-vs | PASS; recorded baseline 861 and 107 module ceilings unchanged | +| gc_runtime_root_holders / self-test | PASS / PASS; 1,542 declarations, 155 registered scanners; 92 planted shapes | +| fmt / diff whitespace / file-size gate / Node-version consistency | PASS | + +Main and head outputs for the three census gates and their self-tests are +byte-identical. Raw debt's inherited violations are `node_stream/async_iterator.rs` +(10 vs ceiling 7), `node_submodules/zlib.rs` (1 unlisted) and +`object/field_get_set/exotic_named_read_tests.rs` (3 unlisted). No ceilings, +inventory verdicts or gate logic are changed by the lane. + +A pre-existing stdlib fixture, `streams::tests::pipe_through_pair_survives_a_moving_getter`, +failed on both unmodified main and head with `Invalid transform writable`, +including an isolated main run. It bypassed program startup and collected +without registering the runtime handle/accessor root scanners. This lane adds +`gc_init()` to that fixture before its deliberate moving collections; its +existing child-moved assertion remains intact. No stream production code is +changed. + +The existing DOMException thread-exit fixture also initializes its observing +heap before starting its worker. The unchanged fixture failed in an isolated +full-feature main run; the corrected fixture and combined head run pass. +With the full runtime features (mimalloc), +initializing that heap only after join can reuse the dead worker's arena block +and classify its stale DOMException header as observer-owned. The fixture +still requires the worker's live brand and rejects the dead worker's brand; +this removes allocator reuse from that ownership observation. diff --git a/docs/native-payload-pattern.md b/docs/native-payload-pattern.md index a4d944ac83..d30d6633c4 100644 --- a/docs/native-payload-pattern.md +++ b/docs/native-payload-pattern.md @@ -26,11 +26,15 @@ object is the only owner of its payload, and the collector sees that edge. * `native_payload::alloc(&FAMILY, payload, external_bytes, own_props)` creates the object and its cell and reports `external_bytes` to GC pacing (`gc_note_external_side_alloc`). +* `native_payload::alloc_closed(&FAMILY, own_props)` creates the same object + and cell in CLOSED state. `lifecycle` distinguishes Open, Closing and Closed. * `native_payload::payload_mut::(this, &FAMILY)` is the receiver check and the payload borrow in one: `Ok(&mut T)`, `Err(Closed)` or `Err(Foreign)`. * `native_payload::close(this, &FAMILY)` is explicit close: it drops the `Box` now (or after the outermost active C call) and releases the bytes. The object stays valid and later reads - as `Closed`. + as `Closed`. Release keeps `finalized = 0` and the owner edge traced. + Only sweep and worker teardown finalize the cell. `attach` reopens the + same cell; object identity, properties, prototype and links stay intact. * If nothing closes it, the sweep that finds the object (and so the cell) dead drops the payload; a worker's teardown drops what is left. The drop runs exactly once on every path. @@ -47,7 +51,7 @@ object is the only owner of its payload, and the collector sees that edge. 4. `external_bytes` counts memory the payload really retains (heap buffers it owns), re-stated when that changes. Do not count transient work buffers or bytes that were already handed to JS (#11549). -5. Do not hold the `&mut T` from `payload_mut` across `close` of the same +5. Do not hold the `&mut T` from `payload_mut` across `close` or `attach` of the same object or across a call that can re-enter the family on the same object. If the method allocates or calls JS while holding it, root the receiver first (`RuntimeHandleScope::root_nanbox_f64`) and return `this` from the root. @@ -76,7 +80,12 @@ plus dispatch-hub arms): 5. The creator returns `native_payload::alloc(&X_FAMILY, payload, bytes, &[(b"own", value), ...])`. 6. Explicit close / end / final / digest calls `native_payload::close` (or - re-states bytes when node keeps the object usable afterwards). + re-states bytes when node keeps the object usable afterwards). Reopen is + `lifecycle` → open the C resource → `attach` into the same CLOSED cell. + `AttachMiss::{Open, Closing, Finalized, Foreign}` reject the install and + drop its input; never replace a live/busy payload or revive a finalized cell. + Reopenable families stamp `next_open_serial()` in the payload, every child + and resource completion; check `OpenSerial` equality before resource use. 7. Add `("module", "Export") => class id` to `native_payload::export_class_id` so `instanceof` answers. 8. Delete, in the same PR: the `register_*handle` producer, the dispatch-hub @@ -93,7 +102,7 @@ plus dispatch-hub arms): ## Not this shape -* Families with no native state (EventEmitter, AsyncLocalStorage, Headers +* Families with no native state (EventEmitter, Headers metadata): plain fields on an ordinary object, no payload. * Families whose node objects are streams with JS-visible state (`Sign`/`Verify` are `Writable`s with `_events` / `_writableState`): the @@ -147,15 +156,20 @@ S checklist: that can re-enter JS. Bracket every callback-capable call (step, exec, prepare, close, backup, changeset_apply) with `enter` and `finish`. 5. Finish immediately when C returns, before result conversion or anything - that can throw. Save its `Err`, convert the native result, then throw that - saved exception outside C (root it if conversion can allocate). The guard is explicitly finished, not Drop + that can throw. `CallEnd::Threw(value)` throws that exact value outside C; + `CallEnd::Closed` throws the family's closed error without converting or + returning partial results. A callback throw takes priority over close. + The guard is explicitly finished, not Drop based. Nested entries consume their pending throw before returning to the outer callback's JS. 6. `close` returns `Closed`, `Deferred`, `AlreadyClosed` or `Foreign`. While busy, it sets CLOSING; payload access and `link_owner` see closed immediately. The outermost finish drops the resource. Finalization marks the cell finalized **before** invoking its drop thunk, so C destruction - callbacks cannot read a dead owner or call JS. + callbacks cannot read a dead owner or call JS. Explicit release instead + keeps CLOSING set throughout its drop thunk. Clear the JS-state callbacks + array at close, so old registrations do not survive reopen. Every child + checks its `OpenSerial` against the current payload before entry. C checklist: @@ -163,23 +177,31 @@ C checklist: by the catch savepoint. No current-owner TLS stack or latch. 2. Use the owning payload's catch/pending/finish protocol for callbacks; keep decoding, conversion and the eventual rethrow outside the C guard span. -3. Release payload borrows and locks before calling C, as for S. +3. Release payload borrows and locks before calling C, as for S. Handle + `CallEnd::Closed` before result conversion, as for S. A checklist: 1. Set `links_owner: true`. Queues carry `OwnerLink`, never an id. Native workers only send inert links; the owner thread dereferences them. -2. Call `link_ref` once per outstanding operation and once per ref'ed native - handle. Its 0→1 transition pins the **cell** via `pin_object_non_young`; +2. Call `link_ref` once per queued item from queueing until dispatch, plus + once per outstanding operation and once per ref'ed native handle. Its 0→1 transition pins the **cell** via `pin_object_non_young`; the pin traces the owner without arming the young-pin latch. Balance every ref with `link_unref`, including after explicit close. The last unref unpins the cell. A bare link is not a root. -3. The pump uses `link_owner` and roots its result. `None` drops the event; - always unref afterwards. Listener throws follow the pump's normal - uncaught path because no C frames need protecting. -4. Keep the cell alive until all queued links have been unref'ed. A token - cannot be used after the collector frees the cell. Worker teardown drops - remaining payloads with finalized already set and runs no JS. +3. Terminal events use `link_event_owner` (OPEN, CLOSING or CLOSED) and root + its result. Read listeners from JS state at dispatch time, including + listeners added after destroy. Resource events also require OPEN and a + matching `OpenSerial`. `None` or a stale event drops the item; always unref + afterwards, including a throw through the pump's normal uncaught path. +4. Before close, release the keep-alive ref recorded in T. Queue each terminal + end/error/close item under its own ref, then release the payload. For async + fd close, move the raw fd from T into the job; release T immediately and + deliver the job's completion under its own ref. +5. Keep cells alive until queued links are unref'ed. Worker teardown stops + the pump, drops queued plain items without running them or unrefing from + Drop, then finalizes every cell including pinned ones. Never dispatch or + dereference queued links after heap teardown. Pending refs die with it. N checklist: diff --git a/scripts/runtime_rss_ab.py b/scripts/runtime_rss_ab.py new file mode 100644 index 0000000000..4800a025ba --- /dev/null +++ b/scripts/runtime_rss_ab.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Compare Linux runtime RSS with equal executable page-cache preparation. + +Example: runtime_rss_ab.py --main main/app --head head/app --cwd fixtures \ + --out results.json --expected-output node.out -- 1 + +Freshly linked, copied, and objcopy-rewritten ELF files can have different +cached read-ahead extents. Even identical binaries then acquire different +clean file RSS. Reset only these two files (never the host's global cache), +then read them identically before the interleaved runs. --cache existing is +available for an identical-binary control of this effect. +""" + +import argparse +import ctypes +import json +import os +from pathlib import Path +import statistics +import subprocess +import tempfile + + +def prepare_executable(path): + with path.open("rb") as source: + os.fsync(source.fileno()) # DONTNEED leaves dirty pages cached. + os.posix_fadvise(source.fileno(), 0, 0, os.POSIX_FADV_DONTNEED) + while source.read(1024 * 1024): + pass + + +def disable_thp(): + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(41, 1, 0, 0, 0): # PR_SET_THP_DISABLE, per process. + raise OSError(ctypes.get_errno(), "PR_SET_THP_DISABLE") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--main", type=Path, required=True) + parser.add_argument("--head", type=Path, required=True) + parser.add_argument("--cwd", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--expected-output", type=Path) + parser.add_argument("--reps", type=int, default=7) + parser.add_argument("--cache", choices=["prepared", "existing"], default="prepared") + parser.add_argument("args", nargs=argparse.REMAINDER) + args = parser.parse_args() + if args.reps < 1: + parser.error("--reps must be positive") + args.out = args.out.resolve() + args.cwd = args.cwd.resolve() + program_args = args.args[1:] if args.args[:1] == ["--"] else args.args + paths = {"main": args.main.resolve(), "head": args.head.resolve()} + args.out.parent.mkdir(parents=True, exist_ok=True) + if args.cache == "prepared": + for path in paths.values(): + prepare_executable(path) + oracle = args.expected_output.read_bytes() if args.expected_output else None + rows = [] + with tempfile.TemporaryDirectory(prefix="rss-ab-", dir=args.out.parent) as scratch: + rss_file = Path(scratch) / "rss" + for off in [False, True]: + for trial in range(args.reps): + order = ["main", "head"] if trial % 2 == 0 else ["head", "main"] + for arm in order: + result = subprocess.run( + ["/usr/bin/time", "-f", "%M", "-o", str(rss_file), + str(paths[arm]), *program_args], + cwd=args.cwd, capture_output=True, check=True, + preexec_fn=disable_thp if off else None, + ) + if oracle is None: + oracle = result.stdout + if result.stdout != oracle: + raise RuntimeError(f"{arm}: output differs on trial {trial}") + row = dict(arm=arm, thp_off=off, trial=trial, + rss_kib=int(rss_file.read_text())) + rows.append(row) + print(json.dumps(row), flush=True) + summaries = [] + for off in [False, True]: + for arm in paths: + values = [r["rss_kib"] for r in rows if r["arm"] == arm and r["thp_off"] == off] + summaries.append(dict(arm=arm, thp_off=off, min_kib=min(values), + mean_kib=statistics.mean(values), + median_kib=statistics.median(values), max_kib=max(values))) + paired_deltas = [] + for off in [False, True]: + values = {arm: [r["rss_kib"] for r in rows if r["arm"] == arm and r["thp_off"] == off] + for arm in paths} + differences = [head - main for main, head in zip(values["main"], values["head"])] + paired_deltas.append(dict(thp_off=off, samples_kib=differences, + mean_kib=statistics.mean(differences), + median_kib=statistics.median(differences))) + args.out.write_text(json.dumps(dict(cache=args.cache, paths={k: str(v) for k, v in paths.items()}, + args=program_args, rows=rows, summaries=summaries, + paired_deltas=paired_deltas), indent=2) + "\n") + print(json.dumps(summaries, indent=2)) + print(json.dumps(paired_deltas, indent=2)) + + +if __name__ == "__main__": + main()