Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions changelog.d/PENDING-native-payload-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 35 additions & 16 deletions crates/perry-runtime/src/async_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -847,25 +847,44 @@ fn register_hook(callbacks: HookCallbacks, track_promises: bool) -> usize {

fn ensure_async_hook_index(receiver: i64) -> Option<usize> {
let value = crate::value::js_nanbox_pointer(receiver);
match unsafe {
let needs_attach = match unsafe {
crate::native_payload::payload_mut_attached::<AsyncHookPayload>(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::<AsyncHookPayload>(
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]
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/gc/layout_slot_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ unsafe fn visit_gc_rewrite_slot_descriptors_with<const INLINE_LAYOUT: bool>(
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));
}
Expand Down
9 changes: 6 additions & 3 deletions crates/perry-runtime/src/gc/malloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(..) {
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/gc/tests/copying/latch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 16 additions & 6 deletions crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
});
Expand Down Expand Up @@ -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;
Loading
Loading