-
-
Notifications
You must be signed in to change notification settings - Fork 159
fix(runtime): isolate native async test completions #8565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| ### Fixed | ||
|
|
||
| - Isolate native-async registry state by token creator in Rust test builds so | ||
| parallel microtask pumps cannot drain another test's completion, eliminating | ||
| the wrong-thread rejection flake from #8435. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -114,6 +114,12 @@ fn current_thread_id() -> u64 { | |
| hasher.finish() | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| fn token_belongs_to_test_thread(token_ptr: usize, thread_id: u64) -> bool { | ||
| let token = unsafe { &*(token_ptr as *const NativeAsyncCompletion) }; | ||
| token.main_thread_id == thread_id | ||
| } | ||
|
|
||
| fn token_from_ptr<'a>(token: *mut NativeAsyncCompletion) -> Option<&'a NativeAsyncCompletion> { | ||
| if token.is_null() { | ||
| None | ||
|
|
@@ -420,7 +426,29 @@ pub extern "C" fn js_native_async_completion_reject_promise_bits( | |
| pub extern "C" fn js_native_async_process_pending() -> i32 { | ||
| let pending: Vec<usize> = { | ||
| let mut registry = crate::gc::lock_gc_root_registry(registry()); | ||
| registry.pending.drain(..).collect() | ||
| #[cfg(not(test))] | ||
| { | ||
| registry.pending.drain(..).collect() | ||
| } | ||
| #[cfg(test)] | ||
| { | ||
| // Every libtest thread owns a separate runtime heap, but they share | ||
| // this production registry. Leave foreign completions queued for | ||
| // the thread that owns their Promise instead of settling them into | ||
| // this thread's heap. | ||
| let thread_id = current_thread_id(); | ||
| let mut owned = Vec::new(); | ||
| let mut foreign = VecDeque::new(); | ||
| while let Some(token_ptr) = registry.pending.pop_front() { | ||
| if token_belongs_to_test_thread(token_ptr, thread_id) { | ||
| owned.push(token_ptr); | ||
| } else { | ||
| foreign.push_back(token_ptr); | ||
| } | ||
| } | ||
| registry.pending = foreign; | ||
| owned | ||
| } | ||
| }; | ||
| let mut processed = 0i32; | ||
| for token_ptr in pending { | ||
|
|
@@ -518,18 +546,35 @@ pub extern "C" fn js_native_async_drop_promise_token(promise: *mut Promise) { | |
| #[no_mangle] | ||
| pub extern "C" fn js_native_async_has_active() -> i32 { | ||
| let registry = crate::gc::lock_gc_root_registry(registry()); | ||
| if registry.tokens.is_empty() && registry.pending.is_empty() { | ||
| 0 | ||
| } else { | ||
| #[cfg(not(test))] | ||
| let has_active = !registry.tokens.is_empty() || !registry.pending.is_empty(); | ||
| #[cfg(test)] | ||
| let has_active = { | ||
| let thread_id = current_thread_id(); | ||
| registry | ||
| .tokens | ||
| .iter() | ||
| .chain(registry.pending.iter()) | ||
| .any(|&token_ptr| token_belongs_to_test_thread(token_ptr, thread_id)) | ||
| }; | ||
| if has_active { | ||
| 1 | ||
| } else { | ||
| 0 | ||
| } | ||
| } | ||
|
|
||
| /// Mutable GC scanner for live native async token slots. | ||
| pub fn scan_native_async_completion_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { | ||
| let mut registry = crate::gc::lock_gc_root_registry(registry()); | ||
| let mut moved_promises = Vec::new(); | ||
| #[cfg(test)] | ||
| let thread_id = current_thread_id(); | ||
| for &token_ptr in ®istry.tokens { | ||
| #[cfg(test)] | ||
| if !token_belongs_to_test_thread(token_ptr, thread_id) { | ||
| continue; | ||
| } | ||
| let token = unsafe { &*(token_ptr as *const NativeAsyncCompletion) }; | ||
| let mut slots = token | ||
| .slots | ||
|
|
@@ -571,9 +616,16 @@ pub(crate) fn test_native_async_lock() -> std::sync::MutexGuard<'static, ()> { | |
| #[cfg(test)] | ||
| pub(crate) fn test_reset_native_async_registry() { | ||
| let mut registry = crate::gc::lock_gc_root_registry(registry()); | ||
| registry.tokens.clear(); | ||
| registry.by_promise.clear(); | ||
| registry.pending.clear(); | ||
| let thread_id = current_thread_id(); | ||
| registry | ||
| .tokens | ||
| .retain(|&token_ptr| !token_belongs_to_test_thread(token_ptr, thread_id)); | ||
| registry | ||
| .by_promise | ||
| .retain(|_, token_ptr| !token_belongs_to_test_thread(*token_ptr, thread_id)); | ||
| registry | ||
| .pending | ||
| .retain(|&token_ptr| !token_belongs_to_test_thread(token_ptr, thread_id)); | ||
|
Comment on lines
+619
to
+628
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 10 \
'test_reset_native_async_registry|js_native_async_completion_(resolve_bits|reject_bits|reject_string|cancel)|enqueue_payload|STATE_PENDING|STATE_QUEUED' \
crates/perry-runtime/src --glob '*.rs'Repository: PerryTS/perry Length of output: 50369 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- outline ---'
ast-grep outline crates/perry-runtime/src/promise/native_async.rs
printf '%s\n' '--- registry, enqueue, drain, reset ---'
sed -n '30,220p' crates/perry-runtime/src/promise/native_async.rs
sed -n '424,590p' crates/perry-runtime/src/promise/native_async.rs
sed -n '600,645p' crates/perry-runtime/src/promise/native_async.rs
printf '%s\n' '--- all reset callers and related registry operations ---'
rg -n -C 6 \
'test_reset_native_async_registry|test_native_async_lock|registry\(\)|by_promise|pending\.push|tokens\.push|state\.store|compare_exchange' \
crates/perry-runtime/src --glob '*.rs' --glob '!native_async.rs'Repository: PerryTS/perry Length of output: 50370 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- token registration and removal ---'
sed -n '220,320p' crates/perry-runtime/src/promise/native_async.rs
sed -n '520,608p' crates/perry-runtime/src/promise/native_async.rs
printf '%s\n' '--- reset and nearby test helpers ---'
sed -n '607,660p' crates/perry-runtime/src/promise/native_async.rs
printf '%s\n' '--- all native async reset/lock references only ---'
rg -n \
'test_reset_native_async_registry|test_native_async_lock' \
crates/perry-runtime/src/promise/native_async.rs \
crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs
printf '%s\n' '--- native async references outside the implementation ---'
rg -n -C 3 \
'js_native_async_completion_new|js_native_async_process_pending|js_native_async_has_active|js_native_async_completion_(resolve_bits|reject_bits|reject_string|cancel)' \
crates --glob '*.rs' --glob '!crates/perry-runtime/src/promise/native_async.rs' | head -n 240
printf '%s\n' '--- changed-file summary ---'
git diff --stat 4ee1d04b58c6b0aa490d59a00ec3e3e2be14974d 6c764ccc4806c48498e5ef3ce0f7eaf0d2dd98e6Repository: PerryTS/perry Length of output: 23284 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from dataclasses import dataclass
PENDING = "PENDING"
QUEUED = "QUEUED"
COMPLETED = "COMPLETED"
`@dataclass`
class Model:
state: str = PENDING
rooted: bool = True
queued: bool = False
payload: bool = False
def reset(m):
m.rooted = False
m.queued = False
def enqueue(m):
if m.state != PENDING:
return False
m.state = QUEUED
m.payload = True
m.queued = True
return True
def drain(m):
if not m.queued:
return "not-drained"
m.queued = False
m.state = COMPLETED
m.rooted = False
return "settled-from-current-root-set"
m = Model()
reset(m)
accepted = enqueue(m)
print({
"late_enqueue_accepted": accepted,
"state": m.state,
"rooted": m.rooted,
"pending_entry": m.queued,
"payload": m.payload,
"drain_result": drain(m),
})
assert accepted and not m.rooted and m.queued
PYRepository: PerryTS/perry Length of output: 397 Retire local native async tokens before removing their registry roots.
Stop producers before reset, or mark tokens retired and safely discard their queued payloads and handles. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| #[cfg(test)] | ||
|
|
@@ -813,6 +865,19 @@ mod tests { | |
| .expect("thread join"); | ||
|
|
||
| assert_eq!(status, PERRY_NATIVE_ASYNC_WRONG_THREAD); | ||
| let foreign_observation = std::thread::spawn(|| { | ||
| ( | ||
| js_native_async_has_active(), | ||
| js_native_async_process_pending(), | ||
| ) | ||
| }) | ||
| .join() | ||
| .expect("foreign drain thread join"); | ||
| assert_eq!( | ||
| foreign_observation, | ||
| (0, 0), | ||
| "a foreign test thread must not observe or drain this token" | ||
| ); | ||
| assert_eq!(js_native_async_process_pending(), 1); | ||
| assert_eq!(super::super::js_promise_state(promise), 2); | ||
| unsafe { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50370
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50370
🌐 Web query:
Rust std::thread::ThreadId Hash DefaultHasher collision guarantees ThreadId equality documentation💡 Result:
In Rust, the std::thread::ThreadId type and its interaction with hashing and equality are governed by the following guarantees: ThreadId Equality: The ThreadId type implements the Eq and PartialEq traits [1][2]. By definition, equality testing for ThreadId is stable and accurate; two ThreadId instances are equal if and only if they refer to the same thread [3][4]. ThreadId objects are opaque and uniquely identify each thread created during the lifetime of a process [3][1]. The standard library guarantees that ThreadId values are never reused, even after a thread terminates [3][2]. Hashing and DefaultHasher: The ThreadId type also implements the Hash trait [1][2]. However, there are no specific guarantees provided by the standard library regarding collision avoidance when using DefaultHasher with ThreadId [5]. DefaultHasher is explicitly documented as having an unspecified internal algorithm [5][6]. Its output can change across different Rust compiler releases, and it is not intended for use as a stable checksum or for any purpose where consistent, cross-version hash values are required [5][6]. Consequently, while the Hash implementation for ThreadId allows it to be used in hash-based collections (like HashMap or HashSet), you cannot rely on DefaultHasher to provide collision-free properties beyond those inherent to the map implementation itself [5]. In summary, while ThreadId equality is stable and guaranteed unique for the process lifetime, the hashing of a ThreadId via DefaultHasher is unstable and implementation-dependent [3][5].
Citations:
Use
std::thread::ThreadIdas the token owner identity.current_thread_id()hashesThreadIdintou64, which is not collision-free. A collision can let one thread drain, scan, reset, or settle another thread’s tokens. Store and compareThreadIddirectly.🤖 Prompt for AI Agents