Skip to content
Closed
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
5 changes: 5 additions & 0 deletions changelog.d/9791-provider-stream-constructor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fix the native-root provider gate's missing ReadableStream and Response helpers,
and prevent separately built runtime providers from assigning different TLS
values to the same shared cache slot. Provider copies of a thread-local now
claim one declaration identity and reuse its existing storage, preserving the
class registry and GC state while streamed Responses run under moving GC.
109 changes: 77 additions & 32 deletions crates/perry-runtime/src/tls_hot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,11 +564,16 @@ pub(crate) fn hot() -> &'static HotTls {
/// Claimed once, on the first thread that resolves the declaration, and stable
/// for the life of the process — so every thread finds the same declaration at
/// the same index in its own cache.
pub struct SlotId(std::sync::atomic::AtomicU32);
pub struct SlotId(std::sync::atomic::AtomicU32, &'static str);

impl SlotId {
pub const fn new() -> Self {
Self(std::sync::atomic::AtomicU32::new(SLOT_UNASSIGNED))
Self::named("")
}

#[doc(hidden)]
pub const fn named(name: &'static str) -> Self {
Self(std::sync::atomic::AtomicU32::new(SLOT_UNASSIGNED), name)
}

/// The claimed index, or a sentinel `>= HOT_SLOT_CAPACITY`.
Expand All @@ -592,20 +597,12 @@ impl SlotId {
fn claim(&self) -> u32 {
use std::sync::atomic::Ordering;
maybe_install_stats_hook();
let mut next = match CLAIM_LOCK.lock() {
Ok(next) => next,
Err(poisoned) => poisoned.into_inner(),
};
let current = self.0.load(Ordering::Relaxed);
if current != SLOT_UNASSIGNED {
return current;
}
let idx = if (*next as usize) < HOT_SLOT_CAPACITY {
let idx = *next;
*next += 1;
idx
} else {
SLOT_OVERFLOW
let idx = unsafe {
js_tls_hot_claim_slot(self.1.as_ptr(), self.1.len(), self as *const Self as usize)
};
self.0.store(idx, Ordering::Relaxed);
idx
Expand All @@ -618,21 +615,53 @@ impl Default for SlotId {
}
}

/// The next index [`SlotId::claim`] will hand out. Also the count of
/// declarations claimed so far, which is what
/// [`claimed_slots`] reports and what the capacity test asserts against.
static CLAIM_LOCK: std::sync::Mutex<u32> = std::sync::Mutex::new(0);
/// One slot per logical declaration across provider images. The C entry point
/// below owns this registry even when the runtime's Rust crate hashes differ.
/// Provider images must come from the same source/ABI, as for HotTls itself.
static CLAIM_LOCK: std::sync::Mutex<std::collections::BTreeMap<Vec<u8>, u32>> =
std::sync::Mutex::new(std::collections::BTreeMap::new());

/// How many declarations have claimed a slot in this process.
/// The provider images share HotTls, so slot identities must be shared too.
/// Use a C entry point for preemption even when separate runtime builds have
/// different Rust crate hashes. Names identify declarations, never TLS values.
///
/// Instrumentation for the capacity assertion: overflow is silent by design
/// (the declaration keeps working, slowly), so something has to be able to see
/// how close the process is to the ceiling.
pub fn claimed_slots() -> u32 {
match CLAIM_LOCK.lock() {
Ok(next) => *next,
Err(poisoned) => *poisoned.into_inner(),
/// # Safety
/// For a nonempty name, `name` must point to `len` readable bytes. A name must
/// identify the same thread-local declaration (and value type) in every image.
/// For an anonymous declaration, `anonymous` must be its unique static address.
#[no_mangle]
#[inline(never)] // Calls must remain interposable across provider images.
pub unsafe extern "C" fn js_tls_hot_claim_slot(
name: *const u8,
len: usize,
anonymous: usize,
) -> u32 {
let key = if len == 0 {
format!("anonymous:{anonymous}").into_bytes()
} else {
std::slice::from_raw_parts(name, len).to_vec()
};
let mut slots = CLAIM_LOCK.lock().unwrap_or_else(|p| p.into_inner());
if let Some(&idx) = slots.get(&key) {
return idx;
}
if slots.len() >= HOT_SLOT_CAPACITY {
return SLOT_OVERFLOW;
}
let idx = slots.len() as u32;
slots.insert(key, idx);
idx
}

#[no_mangle]
#[inline(never)]
pub extern "C" fn js_tls_hot_claimed_slots() -> u32 {
CLAIM_LOCK.lock().unwrap_or_else(|p| p.into_inner()).len() as u32
}

/// How many declarations have claimed a slot in this process.
pub fn claimed_slots() -> u32 {
js_tls_hot_claimed_slots()
}

/// How many slots *this thread* has populated.
Expand Down Expand Up @@ -866,7 +895,8 @@ impl<T: 'static> HotKey<T> {
self.slot.raw()
}

/// `value` is the address of this thread's `T`, published by this key.
/// `value` is this thread's `T`, published by this declaration in one of
/// the compatible provider images sharing the cache.
///
/// # Safety
/// `value` must have come from this key's slot or from its own `resolve`.
Expand All @@ -881,19 +911,26 @@ impl<T: 'static> HotKey<T> {
unsafe { &*(value as *const T) }
}

/// Resolve through the real `thread_local!`, claim this declaration's slot
/// if it has none yet, and publish the address for this thread.
/// Claim the shared declaration slot, reuse any published storage, or
/// resolve through the real `thread_local!` and publish it for this thread.
#[cold]
#[inline(never)]
fn resolve_and_cache(&'static self) -> Result<*mut u8, std::thread::AccessError> {
// Resolve first, and outside the claim lock: initialising the value can
// run arbitrary runtime code, including other `perry_thread_local!`
// first touches.
let value = (self.resolve)()?;
// Claim before resolving storage: another provider can already have
// published this declaration in the shared cache. Do not construct or
// overwrite a second copy. The claim lock is released before any TLS
// initializer runs, so nested first touches remain safe.
let mut idx = self.slot.raw();
if idx == SLOT_UNASSIGNED {
idx = self.slot.claim();
}
if (idx as usize) < HOT_SLOT_CAPACITY {
let cached = hot().slot(idx);
if !cached.is_null() {
return Ok(cached);
}
}
let value = (self.resolve)()?;
if (idx as usize) < HOT_SLOT_CAPACITY {
// Arm before publishing: after this store any thread-teardown of
// the value un-publishes the slot it is about to invalidate.
Expand Down Expand Up @@ -965,7 +1002,12 @@ macro_rules! __perry_thread_local_one {
($(#[$attr:meta])* $vis:vis $name:ident, $t:ty, $($init:tt)+) => {
$(#[$attr])*
$vis static $name: $crate::tls_hot::HotKey<$t> = {
static SLOT: $crate::tls_hot::SlotId = $crate::tls_hot::SlotId::new();
// Module/name alone collide for function-local declarations.
// Avoid file!(): Cargo can use relative vs absolute source paths
// for the same crate in workspace and standalone provider builds.
static SLOT: $crate::tls_hot::SlotId = $crate::tls_hot::SlotId::named(concat!(
module_path!(), "::", stringify!($name), "@", line!(), ":", column!()
));
// `GUARD` is 1 exactly when `$t` has drop glue, so the guard —
// and with it the thread-local's destructor — exists exactly when
// a cached address could otherwise outlive the value.
Expand Down Expand Up @@ -997,6 +1039,9 @@ macro_rules! __perry_thread_local_storage {
};
}

#[cfg(test)]
mod provider_tests;

#[cfg(test)]
mod tests {
/// Every cached address must equal the address of the `thread_local!` it
Expand Down
90 changes: 90 additions & 0 deletions crates/perry-runtime/src/tls_hot/provider_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Provider declaration identity and shared-storage regressions (#9791).

/// Model two separately compiled provider copies of one declaration.
/// Merely allocating noncolliding indices is insufficient: both handles
/// must use the same storage and only one initializer/destructor may run.
#[test]
fn provider_copies_share_storage_without_initializing_a_second_value() {
use std::sync::atomic::{AtomicUsize, Ordering};
static INITIALIZED: AtomicUsize = AtomicUsize::new(0);
static DROPPED: AtomicUsize = AtomicUsize::new(0);
struct Probe(std::cell::Cell<u64>);
impl Probe {
fn new() -> Self {
INITIALIZED.fetch_add(1, Ordering::SeqCst);
Self(std::cell::Cell::new(0))
}
}
impl Drop for Probe {
fn drop(&mut self) {
DROPPED.fetch_add(1, Ordering::SeqCst);
}
}
type Storage = super::HotCell<Probe, 1>;
thread_local! {
static FIRST_STORAGE: Storage = Storage::new(Probe::new());
static SECOND_STORAGE: Storage = Storage::new(Probe::new());
}
static FIRST_SLOT: super::SlotId = super::SlotId::named("provider-test::shared");
static SECOND_SLOT: super::SlotId = super::SlotId::named("provider-test::shared");
static FIRST: super::HotKey<Probe> = super::HotKey::new(
&FIRST_SLOT,
|| FIRST_STORAGE.try_with(|c| c.value_addr()),
|idx| {
let _ = FIRST_STORAGE.try_with(|c| c.arm_guard(idx));
},
);
static SECOND: super::HotKey<Probe> = super::HotKey::new(
&SECOND_SLOT,
|| SECOND_STORAGE.try_with(|c| c.value_addr()),
|idx| {
let _ = SECOND_STORAGE.try_with(|c| c.arm_guard(idx));
},
);
// Reverse which provider is touched first, and overlap the threads to
// exercise independent claim atomics and isolate each thread's value.
let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
let workers: Vec<_> = (0..8)
.map(|i| {
let barrier = barrier.clone();
std::thread::spawn(move || {
let (first, second) = if i % 2 == 0 {
(&FIRST, &SECOND)
} else {
(&SECOND, &FIRST)
};
first.with(|p| p.0.set(i + 100));
barrier.wait();
assert_eq!(second.with(|p| p.0.get()), i + 100);
assert_eq!(
first.with(|p| p as *const Probe),
second.with(|p| p as *const Probe)
);
})
})
.collect();
for worker in workers {
worker.join().expect("provider probe thread panicked");
}
assert_eq!(FIRST.slot_index(), SECOND.slot_index());
assert!((FIRST.slot_index() as usize) < super::HOT_SLOT_CAPACITY);
assert_eq!(INITIALIZED.load(Ordering::SeqCst), 8);
assert_eq!(DROPPED.load(Ordering::SeqCst), 8);
}

/// Function-local declarations can have the same module and identifier;
/// the macro's source coordinates must keep their storage independent.
#[test]
fn same_named_local_declarations_remain_distinct() {
fn first() -> u32 {
crate::perry_thread_local! { static LOCAL: std::cell::Cell<u64> = const { std::cell::Cell::new(11) }; }
assert_eq!(LOCAL.with(|p| p.get()), 11);
LOCAL.slot_index()
}
fn second() -> u32 {
crate::perry_thread_local! { static LOCAL: std::cell::Cell<u64> = const { std::cell::Cell::new(22) }; }
assert_eq!(LOCAL.with(|p| p.get()), 22);
LOCAL.slot_index()
}
assert_ne!(first(), second());
}
2 changes: 2 additions & 0 deletions tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ stdlib_provider_exports=(
js_headers_set
js_readable_stream_get_reader_with_options
js_readable_stream_new_from_source_object
js_readable_stream_new_with_strategy_and_source_type
js_reader_read
js_response_body
js_response_body_init_ptr
js_response_body_init_reset
js_response_get_headers
js_response_new
js_stdlib_init_dispatch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ unsafe extern "C" fn pin_issue_8038_response_surface() {
let _ = perry_stdlib::js_headers_set(0.0, std::ptr::null(), std::ptr::null());
let _ = perry_stdlib::js_headers_append(0.0, std::ptr::null(), std::ptr::null());
let _ = perry_stdlib::js_headers_get(0.0, std::ptr::null());
let _ = perry_stdlib::js_response_body_init_reset();
let _ = perry_stdlib::js_response_body_init_ptr(0.0);
let _ = perry_stdlib::js_response_new(std::ptr::null(), 0.0, std::ptr::null(), 0.0);
let _ = perry_stdlib::js_response_get_headers(0.0);
let _ = perry_stdlib::js_fetch_response_status(0.0);
let _ = perry_stdlib::js_fetch_response_status_text(0.0);
let _ = perry_stdlib::js_response_body(0.0);
let _ = perry_stdlib::js_readable_stream_new_from_source_object(0.0, 0.0);
let _ =
perry_stdlib::js_readable_stream_new_with_strategy_and_source_type(0.0, 0.0, 0.0, 0.0, 0.0);
let _ = perry_stdlib::js_readable_stream_get_reader_with_options(0.0, 0.0);
let _ = perry_stdlib::js_reader_read(0.0);
perry_stdlib::js_stdlib_init_dispatch();
Expand Down
Loading