From 6b4847abf0922a1401ff66dea52ab0e7f7496941 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Mon, 23 Mar 2026 19:15:31 +0200 Subject: [PATCH 01/10] StaticApi threading test --- tools/managed-mem-bench/Cargo.toml | 8 + .../src/{main.rs => bench_leak.rs} | 0 .../managed-mem-bench/src/bench_threading.rs | 191 ++++++++++++++++++ 3 files changed, 199 insertions(+) rename tools/managed-mem-bench/src/{main.rs => bench_leak.rs} (100%) create mode 100644 tools/managed-mem-bench/src/bench_threading.rs diff --git a/tools/managed-mem-bench/Cargo.toml b/tools/managed-mem-bench/Cargo.toml index 7045d45dfb..ae35f9e47f 100644 --- a/tools/managed-mem-bench/Cargo.toml +++ b/tools/managed-mem-bench/Cargo.toml @@ -4,6 +4,14 @@ version = "0.1.0" edition = "2024" publish = false +[[bin]] +name = "bench-leak" +path = "src/bench_leak.rs" + +[[bin]] +name = "bench-threading" +path = "src/bench_threading.rs" + [dependencies.multiversx-sc] version = "0.65.0" path = "../../framework/base" diff --git a/tools/managed-mem-bench/src/main.rs b/tools/managed-mem-bench/src/bench_leak.rs similarity index 100% rename from tools/managed-mem-bench/src/main.rs rename to tools/managed-mem-bench/src/bench_leak.rs diff --git a/tools/managed-mem-bench/src/bench_threading.rs b/tools/managed-mem-bench/src/bench_threading.rs new file mode 100644 index 0000000000..c13de28b04 --- /dev/null +++ b/tools/managed-mem-bench/src/bench_threading.rs @@ -0,0 +1,191 @@ +//! Tests for `StaticApi` and managed types in a multi-threaded environment. +//! +//! `StaticApi` stores its `ManagedTypeContainer` in thread-local storage, so every OS +//! thread owns a fully independent handle space. These tests verify three properties: +//! +//! 1. **Thread isolation** – handles with the same numeric value on different threads +//! hold independent data; writing to one thread's container leaves the other intact. +//! +//! 2. **Reset isolation** – calling `StaticApi::reset()` on thread A does *not* clear +//! the handles that are live on thread B. +//! +//! 3. **Concurrent construction safety** – many threads can create managed types in +//! parallel without panics, deadlocks, or data corruption. + +use std::{ + sync::{Arc, Barrier}, + thread, +}; + +use multiversx_sc::imports::*; +use multiversx_sc_scenario::api::StaticApi; + +// --------------------------------------------------------------------------- +// Test 1 – Thread isolation +// --------------------------------------------------------------------------- +// Each thread creates a ManagedBuffer with its own unique bytes. Because the +// ManagedTypeContainer is thread-local, the first handle allocated on every +// thread is handle 0, yet it stores completely different data. We verify this +// by reading the bytes back on each thread after all threads have finished +// writing, ensuring no thread's payload leaked into another thread's container. +fn test_thread_isolation() { + const NUM_THREADS: usize = 8; + let barrier = Arc::new(Barrier::new(NUM_THREADS)); + + let handles: Vec<_> = (0..NUM_THREADS) + .map(|id| { + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + StaticApi::reset(); + + // Each thread writes a unique 4-byte pattern derived from its id. + let payload = vec![id as u8; 4]; + let buf = ManagedBuffer::::new_from_bytes(&payload); + + // Synchronise: all threads must have written before any reads. + barrier.wait(); + + // Read back on the same thread – must match what *this* thread wrote. + let result = buf.to_boxed_bytes(); + let bytes = result.as_slice(); + assert_eq!( + bytes, + &payload[..], + "Thread {id}: expected {:?}, got {:?}", + payload, + bytes + ); + }) + }) + .collect(); + + for h in handles { + h.join().expect("thread panicked"); + } + + println!("[PASS] test_thread_isolation"); +} + +// --------------------------------------------------------------------------- +// Test 2 – Reset isolation +// --------------------------------------------------------------------------- +// Thread B creates a ManagedBuffer, then signals thread A to call +// `StaticApi::reset()`. After the reset, thread B reads its buffer back and +// asserts that the value is still intact – proving that thread A's reset did +// not touch thread B's container. +fn test_reset_isolation() { + use std::sync::{Condvar, Mutex}; + + // Two-phase hand-shake: B signals A to reset, then A signals B to check. + let pair = Arc::new((Mutex::new(0u8), Condvar::new())); + let pair_b = Arc::clone(&pair); + + let thread_b = thread::spawn(move || { + StaticApi::reset(); + let payload = b"hello from B"; + let buf = ManagedBuffer::::new_from_bytes(payload); + + // Phase 1: tell thread A it may call reset(). + { + let (lock, cvar) = &*pair_b; + let mut state = lock.lock().unwrap(); + *state = 1; + cvar.notify_one(); + } + + // Phase 2: wait for thread A to finish its reset. + { + let (lock, cvar) = &*pair_b; + let mut state = lock.lock().unwrap(); + while *state < 2 { + state = cvar.wait(state).unwrap(); + } + } + + // Thread B's buffer must still hold the original bytes. + let result = buf.to_boxed_bytes(); + let bytes = result.as_slice(); + assert_eq!( + bytes, payload, + "Thread B's buffer was corrupted after thread A's reset" + ); + }); + + // Thread A: wait for B to create its buffer, reset *A's* container, notify B. + { + let (lock, cvar) = &*pair; + let mut state = lock.lock().unwrap(); + while *state < 1 { + state = cvar.wait(state).unwrap(); + } + } + StaticApi::reset(); // only clears thread A's container + { + let (lock, cvar) = &*pair; + let mut state = lock.lock().unwrap(); + *state = 2; + cvar.notify_one(); + } + + thread_b.join().expect("thread B panicked"); + println!("[PASS] test_reset_isolation"); +} + +// --------------------------------------------------------------------------- +// Test 3 – Concurrent construction safety +// --------------------------------------------------------------------------- +// Spin up many threads, each allocating a batch of managed types as fast as +// possible, all starting at the same instant (via a Barrier). No panics or +// deadlocks should occur, and every value must round-trip correctly. +fn test_concurrent_construction() { + const NUM_THREADS: usize = 16; + const ITEMS_PER_THREAD: usize = 1_000; + let barrier = Arc::new(Barrier::new(NUM_THREADS)); + + let handles: Vec<_> = (0..NUM_THREADS) + .map(|id| { + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + StaticApi::reset(); + barrier.wait(); // start all threads simultaneously + + // Create a mix of managed types and verify each one. + for i in 0..ITEMS_PER_THREAD { + let n = (id * ITEMS_PER_THREAD + i) as u64; + + let big = BigUint::::from(n); + assert_eq!( + big.to_u64(), + Some(n), + "Thread {id} item {i}: BigUint round-trip failed" + ); + + let payload = n.to_be_bytes(); + let buf = ManagedBuffer::::new_from_bytes(&payload); + let result = buf.to_boxed_bytes(); + assert_eq!( + result.as_slice(), + &payload, + "Thread {id} item {i}: ManagedBuffer round-trip failed" + ); + } + + StaticApi::reset(); + }) + }) + .collect(); + + for h in handles { + h.join().expect("thread panicked"); + } + + println!("[PASS] test_concurrent_construction"); +} + +fn main() { + println!("\n=== StaticApi multi-thread tests ===\n"); + test_thread_isolation(); + test_reset_isolation(); + test_concurrent_construction(); + println!("\nAll tests passed."); +} From 805e7fb958b55057fdb03576deab4387a486e608 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Fri, 27 Mar 2026 15:17:52 +0200 Subject: [PATCH 02/10] Static Api more threading tests (revealing Send issue) --- .../managed-mem-bench/src/bench_threading.rs | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/tools/managed-mem-bench/src/bench_threading.rs b/tools/managed-mem-bench/src/bench_threading.rs index c13de28b04..68faa0f199 100644 --- a/tools/managed-mem-bench/src/bench_threading.rs +++ b/tools/managed-mem-bench/src/bench_threading.rs @@ -1,7 +1,7 @@ //! Tests for `StaticApi` and managed types in a multi-threaded environment. //! //! `StaticApi` stores its `ManagedTypeContainer` in thread-local storage, so every OS -//! thread owns a fully independent handle space. These tests verify three properties: +//! thread owns a fully independent handle space. These tests verify five properties: //! //! 1. **Thread isolation** – handles with the same numeric value on different threads //! hold independent data; writing to one thread's container leaves the other intact. @@ -11,13 +11,27 @@ //! //! 3. **Concurrent construction safety** – many threads can create managed types in //! parallel without panics, deadlocks, or data corruption. +//! +//! 4. **Handle identity is thread-local** – `ManagedBuffer` is `Send` +//! (it is just a wrapper around an `i32`), yet moving or copying the raw handle +//! integer to another thread gives meaningless results: each thread allocates +//! handles starting at 0, so the same i32 value on two threads refers to +//! completely different entries (or no entry at all) in the receiving thread's +//! container. +//! +//! 5. **Correct cross-thread data transfer** – the safe pattern is to materialise +//! managed-type values into plain Rust types (`BoxedBytes`, `Vec`, `u64`, …) +//! on the source thread, send those plain values across the thread boundary, and +//! reconstruct the managed types on the destination thread. use std::{ sync::{Arc, Barrier}, thread, }; +use multiversx_sc::api::HandleConstraints; use multiversx_sc::imports::*; +use multiversx_sc::types::ManagedType; use multiversx_sc_scenario::api::StaticApi; // --------------------------------------------------------------------------- @@ -182,10 +196,140 @@ fn test_concurrent_construction() { println!("[PASS] test_concurrent_construction"); } +// --------------------------------------------------------------------------- +// Test 4 – Handle identity is thread-local +// --------------------------------------------------------------------------- +// `ManagedBuffer` is `Send` at the type level (it is just an i32), +// but the integer is meaningless outside the thread that created it. +// +// Every fresh thread-local container assigns handles starting at 0, so two +// independent threads both call their first allocation "handle 0", yet the +// data stored at handle 0 is completely independent per thread. +// +// Consequence: copying (or moving) the raw handle number to another thread +// does NOT give you access to the original data — you would silently read +// whatever the receiving thread happens to have stored at that index, or +// get a panic if its container is empty. +fn test_handle_identity_is_thread_local() { + use std::sync::mpsc; + + // Compile-time proof that the types are Send (they wrap a plain i32). + fn assert_send() {} + assert_send::>(); + assert_send::>(); + assert_send::>(); + + // Thread A stores "hello from A" and reports which handle number it was + // assigned, along with the actual bytes it read back. + let (tx, rx) = mpsc::channel::<(i32, Vec)>(); + + let thread_a = thread::spawn(move || { + StaticApi::reset(); + let buf = ManagedBuffer::::new_from_bytes(b"hello from A"); + // get_handle() returns i32 for StaticApi (HandleType = RawHandle = i32). + let raw: i32 = buf.get_handle().get_raw_handle(); + let bytes = buf.to_boxed_bytes().as_slice().to_vec(); + tx.send((raw, bytes)).unwrap(); + // buf is dropped here, inside thread A's container – no cross-thread drop. + }); + thread_a.join().unwrap(); + + let (handle_from_a, data_from_a) = rx.recv().unwrap(); + + // Main thread: fresh container, first allocation also gets handle 0. + StaticApi::reset(); + let buf_main = ManagedBuffer::::new_from_bytes(b"hello from main"); + let raw_main: i32 = buf_main.get_handle().get_raw_handle(); + + // Both threads assigned the same handle number from their own containers. + assert_eq!( + handle_from_a, raw_main, + "fresh thread-local containers both start numbering handles at 0" + ); + + // The data at that handle on the main thread is NOT thread A's data. + let main_bytes = buf_main.to_boxed_bytes().as_slice().to_vec(); + assert_ne!( + main_bytes, data_from_a, + "same handle number holds DIFFERENT data on main thread vs thread A" + ); + assert_eq!(main_bytes, b"hello from main"); + + println!( + "[PASS] test_handle_identity_is_thread_local \ + (handle #{handle_from_a}: thread A had {:?}, main thread has {:?})", + String::from_utf8_lossy(&data_from_a), + String::from_utf8_lossy(&main_bytes), + ); +} + +// --------------------------------------------------------------------------- +// Test 5 – Correct cross-thread data transfer via serialisation +// --------------------------------------------------------------------------- +// Because handles are thread-local, you cannot move a managed-type *object* +// across threads and expect it to work. The correct pattern is: +// +// 1. Materialise the value into a plain Rust type on the source thread. +// 2. Send that plain value (it is genuinely Send/Sync). +// 3. Reconstruct the managed type from the plain value on the destination +// thread. +// +// This test runs a tiny "pipeline": a producer thread creates several managed +// values, serialises them, and sends them through an `mpsc` channel. The +// consumer thread (main) deserialises them back into managed types and verifies +// the round-trip. +fn test_cross_thread_data_transfer() { + use std::sync::mpsc; + + // The messages we send across the boundary are plain Rust types – no + // managed handles, no thread-local state. + struct Payload { + buffer_bytes: Vec, + biguint_bytes: Vec, // big-endian serialisation of a BigUint + native_u64: u64, + } + + let (tx, rx) = mpsc::channel::(); + + let producer = thread::spawn(move || { + StaticApi::reset(); + + let buf = ManagedBuffer::::new_from_bytes(b"cross-thread payload"); + let big = BigUint::::from(0xDEAD_BEEF_u64); + let n: u64 = 42; + + // Materialise before sending. + tx.send(Payload { + buffer_bytes: buf.to_boxed_bytes().as_slice().to_vec(), + biguint_bytes: big.to_bytes_be().as_slice().to_vec(), + native_u64: n, + }) + .unwrap(); + }); + producer.join().unwrap(); + + let payload = rx.recv().unwrap(); + + // Consumer (main thread): reconstruct managed types from the plain values. + StaticApi::reset(); + + let buf = ManagedBuffer::::new_from_bytes(&payload.buffer_bytes); + assert_eq!(buf.to_boxed_bytes().as_slice(), b"cross-thread payload"); + + let big = BigUint::::from_bytes_be(&payload.biguint_bytes); + assert_eq!(big.to_u64(), Some(0xDEAD_BEEF_u64)); + + assert_eq!(payload.native_u64, 42u64); + + println!("[PASS] test_cross_thread_data_transfer"); +} + fn main() { println!("\n=== StaticApi multi-thread tests ===\n"); test_thread_isolation(); test_reset_isolation(); test_concurrent_construction(); + test_handle_identity_is_thread_local(); + test_cross_thread_data_transfer(); println!("\nAll tests passed."); } From 28dc62e4b53a2ac04b61f17217037510a6f4118d Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Fri, 27 Mar 2026 15:19:58 +0200 Subject: [PATCH 03/10] DebugHandle !Send --- framework/scenario/src/api/impl_vh/debug_handle_vh.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/framework/scenario/src/api/impl_vh/debug_handle_vh.rs b/framework/scenario/src/api/impl_vh/debug_handle_vh.rs index c855c8d542..7df2c591de 100644 --- a/framework/scenario/src/api/impl_vh/debug_handle_vh.rs +++ b/framework/scenario/src/api/impl_vh/debug_handle_vh.rs @@ -1,3 +1,4 @@ +use core::marker::PhantomData; use std::sync::Weak; use multiversx_chain_vm::host::context::{TxContext, TxContextRef}; @@ -14,6 +15,12 @@ pub struct DebugHandle { /// Using the pointer after the context is released will panic. pub(crate) context: Weak, raw_handle: RawHandle, + + /// This field causes DebugHandle not to be `Send` or `Sync`, + /// which is desirable since the handle is only valid on the thread of the original context. + /// + /// This restriction is not enough to ensure safety (the context also helps), but it is an additional line of defense against misuse. + _phantom: PhantomData<*const ()>, } impl DebugHandle { @@ -22,6 +29,7 @@ impl DebugHandle { Self { context, raw_handle, + _phantom: PhantomData, } } From 12b93db14094e6751926e672446d3a1d4b0cd369 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Fri, 27 Mar 2026 16:11:29 +0200 Subject: [PATCH 04/10] StaticApiHandle, !Send --- framework/scenario/src/api.rs | 3 +- framework/scenario/src/api/impl_vh.rs | 2 + .../scenario/src/api/impl_vh/static_api.rs | 9 ++- .../src/api/impl_vh/static_api_handle.rs | 66 +++++++++++++++++++ .../derive_managed_vec_item_biguint_test.rs | 2 +- ...anaged_vec_item_esdt_token_payment_test.rs | 2 +- .../managed-mem-bench/src/bench_threading.rs | 29 ++++---- 7 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 framework/scenario/src/api/impl_vh/static_api_handle.rs diff --git a/framework/scenario/src/api.rs b/framework/scenario/src/api.rs index b689cb8f2c..5eb8729b9f 100644 --- a/framework/scenario/src/api.rs +++ b/framework/scenario/src/api.rs @@ -6,5 +6,6 @@ mod vm_api_vh; pub(crate) use impl_vh::i32_to_bool; pub use impl_vh::{ - DebugApi, DebugApiBackend, DebugHandle, SingleTxApi, StaticApi, VMHooksApi, VMHooksApiBackend, + DebugApi, DebugApiBackend, DebugHandle, SingleTxApi, StaticApi, StaticApiHandle, VMHooksApi, + VMHooksApiBackend, }; diff --git a/framework/scenario/src/api/impl_vh.rs b/framework/scenario/src/api/impl_vh.rs index ba4177fed9..5ef58d1902 100644 --- a/framework/scenario/src/api/impl_vh.rs +++ b/framework/scenario/src/api/impl_vh.rs @@ -2,6 +2,7 @@ mod debug_api; mod debug_handle_vh; mod single_tx_api; mod static_api; +mod static_api_handle; mod vh_single_tx_api; mod vh_static_api; mod vm_hooks_api; @@ -11,6 +12,7 @@ pub use debug_api::{DebugApi, DebugApiBackend}; pub use debug_handle_vh::DebugHandle; pub use single_tx_api::SingleTxApi; pub use static_api::StaticApi; +pub use static_api_handle::StaticApiHandle; pub use vh_single_tx_api::{SingleTxApiData, SingleTxApiVMHooksContext}; pub use vh_static_api::StaticApiVMHooksContext; pub use vm_hooks_api::VMHooksApi; diff --git a/framework/scenario/src/api/impl_vh/static_api.rs b/framework/scenario/src/api/impl_vh/static_api.rs index 613ca3f441..af34fbc4c9 100644 --- a/framework/scenario/src/api/impl_vh/static_api.rs +++ b/framework/scenario/src/api/impl_vh/static_api.rs @@ -1,9 +1,12 @@ use multiversx_chain_vm::host::vm_hooks::VMHooksDispatcher; use multiversx_chain_vm_executor::VMHooksEarlyExit; -use multiversx_sc::{api::RawHandle, types::Address}; +use multiversx_sc::types::Address; use std::sync::Mutex; -use crate::executor::debug::{StaticVarData, VMHooksDebugger}; +use crate::{ + api::StaticApiHandle, + executor::debug::{StaticVarData, VMHooksDebugger}, +}; use super::{StaticApiVMHooksContext, VMHooksApi, VMHooksApiBackend}; @@ -21,7 +24,7 @@ thread_local! { pub struct StaticApiBackend; impl VMHooksApiBackend for StaticApiBackend { - type HandleType = RawHandle; + type HandleType = StaticApiHandle; fn with_vm_hooks(f: F) -> R where diff --git a/framework/scenario/src/api/impl_vh/static_api_handle.rs b/framework/scenario/src/api/impl_vh/static_api_handle.rs new file mode 100644 index 0000000000..f4b889a475 --- /dev/null +++ b/framework/scenario/src/api/impl_vh/static_api_handle.rs @@ -0,0 +1,66 @@ +use core::marker::PhantomData; + +use multiversx_sc::{ + api::{HandleConstraints, RawHandle}, + codec::TryStaticCast, +}; + +#[derive(Clone)] +pub struct StaticApiHandle { + raw_handle: RawHandle, + _phantom: PhantomData<*const ()>, +} + +impl StaticApiHandle { + /// Should almost never call directly, only used directly in a test. + pub fn new(raw_handle: RawHandle) -> Self { + Self { + raw_handle, + _phantom: PhantomData, + } + } +} + +impl core::fmt::Debug for StaticApiHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + RawHandle::fmt(&self.raw_handle, f) + } +} + +impl HandleConstraints for StaticApiHandle { + fn new(handle: multiversx_sc::api::RawHandle) -> Self { + StaticApiHandle::new(handle) + } + + fn to_be_bytes(&self) -> [u8; 4] { + self.raw_handle.to_be_bytes() + } + + fn get_raw_handle(&self) -> RawHandle { + self.raw_handle + } + + fn get_raw_handle_unchecked(&self) -> RawHandle { + self.raw_handle + } +} + +impl PartialEq for StaticApiHandle { + fn eq(&self, other: &RawHandle) -> bool { + &self.raw_handle == other + } +} + +impl PartialEq for StaticApiHandle { + fn eq(&self, other: &StaticApiHandle) -> bool { + self.raw_handle == other.raw_handle + } +} + +impl From for StaticApiHandle { + fn from(handle: i32) -> Self { + StaticApiHandle::new(handle) + } +} + +impl TryStaticCast for StaticApiHandle {} diff --git a/framework/scenario/tests/derive_managed_vec_item_biguint_test.rs b/framework/scenario/tests/derive_managed_vec_item_biguint_test.rs index fd0e293d49..7520be98c7 100644 --- a/framework/scenario/tests/derive_managed_vec_item_biguint_test.rs +++ b/framework/scenario/tests/derive_managed_vec_item_biguint_test.rs @@ -1,5 +1,5 @@ use multiversx_sc::{ - api::ManagedTypeApi, + api::{HandleConstraints, ManagedTypeApi}, codec::{ self, derive::{NestedDecode, NestedEncode, TopDecode, TopEncode}, diff --git a/framework/scenario/tests/derive_managed_vec_item_esdt_token_payment_test.rs b/framework/scenario/tests/derive_managed_vec_item_esdt_token_payment_test.rs index da6eb1c70b..6398f490d4 100644 --- a/framework/scenario/tests/derive_managed_vec_item_esdt_token_payment_test.rs +++ b/framework/scenario/tests/derive_managed_vec_item_esdt_token_payment_test.rs @@ -1,5 +1,5 @@ use multiversx_sc::{ - api::ManagedTypeApi, + api::{HandleConstraints, ManagedTypeApi}, codec::{ self, derive::{NestedDecode, NestedEncode, TopDecode, TopEncode}, diff --git a/tools/managed-mem-bench/src/bench_threading.rs b/tools/managed-mem-bench/src/bench_threading.rs index 68faa0f199..df857cd24c 100644 --- a/tools/managed-mem-bench/src/bench_threading.rs +++ b/tools/managed-mem-bench/src/bench_threading.rs @@ -12,12 +12,11 @@ //! 3. **Concurrent construction safety** – many threads can create managed types in //! parallel without panics, deadlocks, or data corruption. //! -//! 4. **Handle identity is thread-local** – `ManagedBuffer` is `Send` -//! (it is just a wrapper around an `i32`), yet moving or copying the raw handle -//! integer to another thread gives meaningless results: each thread allocates -//! handles starting at 0, so the same i32 value on two threads refers to -//! completely different entries (or no entry at all) in the receiving thread's -//! container. +//! 4. **Handle identity is thread-local** – `ManagedBuffer` is `!Send`: +//! the compiler prevents moving managed values across threads, which is correct +//! because the underlying storage is thread-local. Each thread allocates handles +//! starting at 0, so the same i32 value on two threads refers to completely +//! different entries in each thread's independent container. //! //! 5. **Correct cross-thread data transfer** – the safe pattern is to materialise //! managed-type values into plain Rust types (`BoxedBytes`, `Vec`, `u64`, …) @@ -199,26 +198,20 @@ fn test_concurrent_construction() { // --------------------------------------------------------------------------- // Test 4 – Handle identity is thread-local // --------------------------------------------------------------------------- -// `ManagedBuffer` is `Send` at the type level (it is just an i32), -// but the integer is meaningless outside the thread that created it. +// `ManagedBuffer` is `!Send`: the compiler prevents moving managed +// values across threads, which is correct because the underlying storage is +// thread-local. // // Every fresh thread-local container assigns handles starting at 0, so two // independent threads both call their first allocation "handle 0", yet the // data stored at handle 0 is completely independent per thread. // -// Consequence: copying (or moving) the raw handle number to another thread -// does NOT give you access to the original data — you would silently read -// whatever the receiving thread happens to have stored at that index, or -// get a panic if its container is empty. +// The safe way to observe this is to materialise only the raw i32 handle +// number and the serialised bytes on the source thread, then compare on the +// destination thread. fn test_handle_identity_is_thread_local() { use std::sync::mpsc; - // Compile-time proof that the types are Send (they wrap a plain i32). - fn assert_send() {} - assert_send::>(); - assert_send::>(); - assert_send::>(); - // Thread A stores "hello from A" and reports which handle number it was // assigned, along with the actual bytes it read back. let (tx, rx) = mpsc::channel::<(i32, Vec)>(); From a9ec51c3027961c39bd4c7fb688a253984530bde Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Fri, 27 Mar 2026 16:14:09 +0200 Subject: [PATCH 05/10] DebugHandle file rename --- framework/scenario/src/api/impl_vh.rs | 4 ++-- .../src/api/impl_vh/{debug_handle_vh.rs => debug_handle.rs} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename framework/scenario/src/api/impl_vh/{debug_handle_vh.rs => debug_handle.rs} (100%) diff --git a/framework/scenario/src/api/impl_vh.rs b/framework/scenario/src/api/impl_vh.rs index 5ef58d1902..7eb3bc6d1e 100644 --- a/framework/scenario/src/api/impl_vh.rs +++ b/framework/scenario/src/api/impl_vh.rs @@ -1,5 +1,5 @@ mod debug_api; -mod debug_handle_vh; +mod debug_handle; mod single_tx_api; mod static_api; mod static_api_handle; @@ -9,7 +9,7 @@ mod vm_hooks_api; mod vm_hooks_backend; pub use debug_api::{DebugApi, DebugApiBackend}; -pub use debug_handle_vh::DebugHandle; +pub use debug_handle::DebugHandle; pub use single_tx_api::SingleTxApi; pub use static_api::StaticApi; pub use static_api_handle::StaticApiHandle; diff --git a/framework/scenario/src/api/impl_vh/debug_handle_vh.rs b/framework/scenario/src/api/impl_vh/debug_handle.rs similarity index 100% rename from framework/scenario/src/api/impl_vh/debug_handle_vh.rs rename to framework/scenario/src/api/impl_vh/debug_handle.rs From f212429e35b29a60c34624a1d4709ce7aa8b364d Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Mon, 30 Mar 2026 12:29:01 +0300 Subject: [PATCH 06/10] test fix --- framework/base/src/types/managed/managed_type_trait.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/base/src/types/managed/managed_type_trait.rs b/framework/base/src/types/managed/managed_type_trait.rs index 3710df32fc..e554995823 100644 --- a/framework/base/src/types/managed/managed_type_trait.rs +++ b/framework/base/src/types/managed/managed_type_trait.rs @@ -32,7 +32,7 @@ pub trait ManagedType: Sized { } fn get_raw_handle(&self) -> RawHandle { - self.get_handle().cast_or_signal_error::() + self.get_handle().get_raw_handle() } fn get_raw_handle_unchecked(&self) -> RawHandle { From b2d59cff50b72dc7f0862fcc9b65eaef4b995db6 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Mon, 30 Mar 2026 13:59:03 +0300 Subject: [PATCH 07/10] imports cleanup --- tools/managed-mem-bench/src/bench_threading.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/managed-mem-bench/src/bench_threading.rs b/tools/managed-mem-bench/src/bench_threading.rs index df857cd24c..a70d607f14 100644 --- a/tools/managed-mem-bench/src/bench_threading.rs +++ b/tools/managed-mem-bench/src/bench_threading.rs @@ -29,9 +29,7 @@ use std::{ }; use multiversx_sc::api::HandleConstraints; -use multiversx_sc::imports::*; -use multiversx_sc::types::ManagedType; -use multiversx_sc_scenario::api::StaticApi; +use multiversx_sc_scenario::imports::*; // --------------------------------------------------------------------------- // Test 1 – Thread isolation From fa581f1a89790989f932241f439bb9dc1754e1f8 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Mon, 30 Mar 2026 13:59:50 +0300 Subject: [PATCH 08/10] DebugHandle/StaticHandle !Send + !Sync test --- Cargo.lock | 1 + framework/scenario/Cargo.toml | 3 +++ framework/scenario/src/api/impl_vh/debug_handle.rs | 10 ++++++++++ .../scenario/src/api/impl_vh/static_api_handle.rs | 10 ++++++++++ 4 files changed, 24 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index daf8030016..31365478e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3322,6 +3322,7 @@ dependencies = [ "serde_json", "sha2", "simple-error", + "static_assertions", "unwrap-infallible", ] diff --git a/framework/scenario/Cargo.toml b/framework/scenario/Cargo.toml index 34f67e35aa..21c14be740 100644 --- a/framework/scenario/Cargo.toml +++ b/framework/scenario/Cargo.toml @@ -61,3 +61,6 @@ version = "=0.5.1" [dependencies.multiversx-chain-vm] version = "=0.22.0" path = "../../chain/vm" + +[dev-dependencies] +static_assertions = "1.1" diff --git a/framework/scenario/src/api/impl_vh/debug_handle.rs b/framework/scenario/src/api/impl_vh/debug_handle.rs index 7df2c591de..1646fd713b 100644 --- a/framework/scenario/src/api/impl_vh/debug_handle.rs +++ b/framework/scenario/src/api/impl_vh/debug_handle.rs @@ -109,3 +109,13 @@ impl From for DebugHandle { } impl TryStaticCast for DebugHandle {} + +#[cfg(test)] +mod tests { + use super::DebugHandle; + + // DebugHandle intentionally does not implement Send or Sync + // (enforced via PhantomData<*const ()>), since a handle is only valid + // on the thread that created the underlying context. + static_assertions::assert_not_impl_any!(DebugHandle: Send, Sync); +} diff --git a/framework/scenario/src/api/impl_vh/static_api_handle.rs b/framework/scenario/src/api/impl_vh/static_api_handle.rs index f4b889a475..fb3350feb2 100644 --- a/framework/scenario/src/api/impl_vh/static_api_handle.rs +++ b/framework/scenario/src/api/impl_vh/static_api_handle.rs @@ -64,3 +64,13 @@ impl From for StaticApiHandle { } impl TryStaticCast for StaticApiHandle {} + +#[cfg(test)] +mod tests { + use super::StaticApiHandle; + + // StaticApiHandle intentionally does not implement Send or Sync + // (enforced via PhantomData<*const ()>), since a handle is only valid + // on the thread that created the underlying context. + static_assertions::assert_not_impl_any!(StaticApiHandle: Send, Sync); +} From c72f41cb2a8cc1611479909cb07fa0794c1ff418 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Mon, 30 Mar 2026 14:02:24 +0300 Subject: [PATCH 09/10] doc --- framework/scenario/src/api/impl_vh/static_api_handle.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/framework/scenario/src/api/impl_vh/static_api_handle.rs b/framework/scenario/src/api/impl_vh/static_api_handle.rs index fb3350feb2..4a4a3e876a 100644 --- a/framework/scenario/src/api/impl_vh/static_api_handle.rs +++ b/framework/scenario/src/api/impl_vh/static_api_handle.rs @@ -8,6 +8,9 @@ use multiversx_sc::{ #[derive(Clone)] pub struct StaticApiHandle { raw_handle: RawHandle, + + /// This field causes StaticApiHandle not to be `Send` or `Sync`, + /// which is desirable since the handle is only valid on the thread of the original context. _phantom: PhantomData<*const ()>, } From 5ac255d1bece55748378fe2bd56b16609b4b43f2 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Mon, 30 Mar 2026 14:08:55 +0300 Subject: [PATCH 10/10] managed type !Send + !Sync tests --- framework/scenario/tests/big_float_test.rs | 4 ++++ framework/scenario/tests/big_int_test.rs | 4 ++++ framework/scenario/tests/big_uint_test.rs | 4 ++++ framework/scenario/tests/managed_map_unit_tests.rs | 4 ++++ framework/scenario/tests/token_id_test.rs | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/framework/scenario/tests/big_float_test.rs b/framework/scenario/tests/big_float_test.rs index d103f45208..f5569e7a51 100644 --- a/framework/scenario/tests/big_float_test.rs +++ b/framework/scenario/tests/big_float_test.rs @@ -1,6 +1,10 @@ use multiversx_sc::types::{BigFloat, BigInt, BigUint}; use multiversx_sc_scenario::api::StaticApi; +// BigFloat intentionally does not implement Send or Sync, +// since it holds a managed handle that is only valid on the thread of the original context. +static_assertions::assert_not_impl_any!(BigFloat::: Send, Sync); + #[test] fn big_float_overflow_test_rs() { let exp = 1_080i32; diff --git a/framework/scenario/tests/big_int_test.rs b/framework/scenario/tests/big_int_test.rs index 94273fdfbf..1f90191526 100644 --- a/framework/scenario/tests/big_int_test.rs +++ b/framework/scenario/tests/big_int_test.rs @@ -1,6 +1,10 @@ use multiversx_sc::types::BigInt; use multiversx_sc_scenario::api::StaticApi; +// BigInt intentionally does not implement Send or Sync, +// since it holds a managed handle that is only valid on the thread of the original context. +static_assertions::assert_not_impl_any!(BigInt::: Send, Sync); + #[test] fn test_big_int_add() { let x = BigInt::::from(2); diff --git a/framework/scenario/tests/big_uint_test.rs b/framework/scenario/tests/big_uint_test.rs index 4899a0f71c..8a529bcb0e 100644 --- a/framework/scenario/tests/big_uint_test.rs +++ b/framework/scenario/tests/big_uint_test.rs @@ -1,6 +1,10 @@ use multiversx_sc::types::BigUint; use multiversx_sc_scenario::api::StaticApi; +// BigUint intentionally does not implement Send or Sync, +// since it holds a managed handle that is only valid on the thread of the original context. +static_assertions::assert_not_impl_any!(BigUint::: Send, Sync); + fn assert_big_uint_ln(x: u32, ln_str: &str) { let x = BigUint::::from(x); let ln_x = x.ln(); diff --git a/framework/scenario/tests/managed_map_unit_tests.rs b/framework/scenario/tests/managed_map_unit_tests.rs index 64d2b30c18..0112b793a9 100644 --- a/framework/scenario/tests/managed_map_unit_tests.rs +++ b/framework/scenario/tests/managed_map_unit_tests.rs @@ -1,6 +1,10 @@ use multiversx_sc::types::{ManagedBuffer, ManagedMap}; use multiversx_sc_scenario::api::StaticApi; +// ManagedMap intentionally does not implement Send or Sync, +// since it holds a managed handle that is only valid on the thread of the original context. +static_assertions::assert_not_impl_any!(ManagedMap::: Send, Sync); + #[test] fn key_mutability_test() { let mut map = ManagedMap::::new(); diff --git a/framework/scenario/tests/token_id_test.rs b/framework/scenario/tests/token_id_test.rs index 0d8a88620a..759b471148 100644 --- a/framework/scenario/tests/token_id_test.rs +++ b/framework/scenario/tests/token_id_test.rs @@ -11,6 +11,10 @@ use multiversx_sc_scenario::{ multiversx_sc, token_id, }; +// TokenId intentionally does not implement Send or Sync, +// since it holds a managed handle that is only valid on the thread of the original context. +static_assertions::assert_not_impl_any!(TokenId::: Send, Sync); + #[test] fn test_egld() { assert!(EgldOrEsdtTokenIdentifier::::egld().is_egld());