55#![ cfg( feature = "with-libfunc-profiling" ) ]
66
77use std:: sync:: atomic:: { AtomicU64 , Ordering } ;
8- use std:: sync:: Arc ;
8+ use std:: sync:: { Arc , Mutex } ;
99
1010use cairo_lang_sierra:: program:: Program ;
1111use starknet_types_core:: felt:: Felt ;
1212
13- use crate :: error:: Result ;
13+ use crate :: error:: { Error , Result } ;
1414use crate :: execution_result:: ContractExecutionResult ;
1515use crate :: executor:: AotContractExecutor ;
1616use crate :: metadata:: profiler:: { Profile , ProfilerBinding , ProfilerImpl , LIBFUNC_PROFILE } ;
1717use crate :: starknet:: StarknetSyscallHandler ;
1818use crate :: utils:: BuiltinCosts ;
1919
20+ /// Process-wide lock that serializes calls into [`AotContractExecutor::run_with_libfunc_profile`].
21+ /// The profiler hot-swaps a process-global symbol (`cairo_native__profiler__profile_id`);
22+ /// concurrent callers would race on that write and on the [`LIBFUNC_PROFILE`] slot bookkeeping.
23+ static PROFILE_LOCK : Mutex < ( ) > = Mutex :: new ( ( ) ) ;
24+
2025impl AotContractExecutor {
2126 /// Run the entrypoint with libfunc-level profiling instrumentation.
2227 ///
2328 /// Wraps [`AotContractExecutor::run`] with the bookkeeping the
2429 /// `with-libfunc-profiling` runtime needs:
2530 ///
26- /// 1. Allocates a unique trace ID and inserts an empty `ProfilerImpl` slot in
27- /// [`LIBFUNC_PROFILE`].
28- /// 2. Points the executor's `cairo_native__profiler__profile_id` symbol at the new
29- /// trace ID, saving the previous value.
30- /// 3. Calls `run`. Per-statement samples accumulate in the slot via the runtime
31+ /// 1. Acquires [`PROFILE_LOCK`] so concurrent profile calls serialize on the
32+ /// global trace-id symbol. The lock is recovered if poisoned.
33+ /// 2. Looks up the executor's `cairo_native__profiler__profile_id` symbol. If
34+ /// absent (the .so was compiled without profiling instrumentation) the call
35+ /// returns an error before touching any global state.
36+ /// 3. Allocates a unique trace ID and inserts an empty `ProfilerImpl` slot in
37+ /// [`LIBFUNC_PROFILE`]; points the profile-id symbol at the new ID, saving
38+ /// the previous value.
39+ /// 4. Calls `run`. Per-statement samples accumulate in the slot via the runtime
3140 /// `push_stmt` callback.
32- /// 4. Drains the slot, calls [`ProfilerImpl::get_profile`] with `program`, and hands
33- /// the resulting [`Profile`] to `on_profile`.
34- /// 5. A [`ProfilerGuard`] restores the previous trace ID — and removes the slot if
35- /// the success path didn't — on both success and unwind paths.
41+ /// 5. Drains the slot. On success (and only on success) hands the resulting
42+ /// [`Profile`] to `on_profile`; on failure the callback is not invoked
43+ /// (partial profiles aren't meaningful).
44+ /// 6. A [`ProfilerGuard`] restores the previous trace ID and clears the slot on
45+ /// both the success and unwind paths.
3646 ///
3747 /// `program` must be the Sierra program this executor was compiled from; it's used
3848 /// by `get_profile` to map runtime libfunc IDs back to declarations.
39- ///
40- /// Profiling is intended to run single-threaded; concurrent calls would race on the
41- /// global `trace_id` symbol.
4249 pub fn run_with_libfunc_profile < H , F > (
4350 & self ,
4451 program : & Arc < Program > ,
@@ -53,28 +60,42 @@ impl AotContractExecutor {
5360 H : StarknetSyscallHandler ,
5461 F : FnOnce ( Profile ) ,
5562 {
63+ // Serialize against concurrent profile calls. Recover from a poisoned lock —
64+ // we don't have invariants on the protected state itself; the lock only gates
65+ // access to the global trace-id symbol.
66+ let _profile_lock = PROFILE_LOCK . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
67+
68+ // Look up the profile-id symbol before touching any global state. If the
69+ // executor wasn't compiled with libfunc-profiling instrumentation, the
70+ // symbol is absent — return a typed error rather than panicking.
71+ let trace_id_ptr = self
72+ . find_symbol_ptr ( ProfilerBinding :: ProfileId . symbol ( ) )
73+ . ok_or_else ( || {
74+ Error :: UnexpectedValue ( format ! (
75+ "AOT executor missing libfunc-profiling symbol `{}`; \
76+ was the program compiled with libfunc-profiling enabled?",
77+ ProfilerBinding :: ProfileId . symbol( )
78+ ) )
79+ } ) ?
80+ . cast :: < u64 > ( ) ;
81+
5682 static COUNTER : AtomicU64 = AtomicU64 :: new ( 0 ) ;
5783 let counter = COUNTER . fetch_add ( 1 , Ordering :: Relaxed ) ;
5884
5985 LIBFUNC_PROFILE
6086 . lock ( )
61- . unwrap ( )
87+ . unwrap_or_else ( |e| e . into_inner ( ) )
6288 . insert ( counter, ProfilerImpl :: new ( ) ) ;
6389
64- // The pointer targets a global symbol in the executor's shared library; it lives
65- // for the executor's lifetime. Single-threaded profiling means no concurrent writer.
66- let trace_id_ptr = self
67- . find_symbol_ptr ( ProfilerBinding :: ProfileId . symbol ( ) )
68- . unwrap ( )
69- . cast :: < u64 > ( ) ;
70- // SAFETY: see above. Read/write to a non-null, properly-aligned `*mut u64`.
90+ // SAFETY: the pointer targets a memref-global emitted into the executor's
91+ // shared library; the executor outlives the call. `PROFILE_LOCK` serializes
92+ // us against any other writer, and the JIT/AOT code reads through the same
93+ // address. Reads/writes are aligned `u64`s.
7194 let old_trace_id = unsafe { * trace_id_ptr } ;
7295 unsafe {
7396 * trace_id_ptr = counter;
7497 }
7598
76- // Restore on the success path AND on unwind. On success the caller drains the
77- // slot below; the guard's `remove` is then a no-op.
7899 let _guard = ProfilerGuard {
79100 trace_id_ptr,
80101 old_trace_id,
@@ -83,15 +104,27 @@ impl AotContractExecutor {
83104
84105 let result = self . run ( selector, args, gas, builtin_costs, syscall_handler) ;
85106
86- let profiler = LIBFUNC_PROFILE . lock ( ) . unwrap ( ) . remove ( & counter) . unwrap ( ) ;
87- on_profile ( profiler. get_profile ( program) ) ;
107+ // Drain the slot. `ProfilerGuard::drop` would also remove it; doing it here
108+ // means we hold the lock for the shortest time and can hand the profile to
109+ // the callback. Tolerate a poisoned mutex (we'd lose the profile, not state).
110+ let drained = LIBFUNC_PROFILE
111+ . lock ( )
112+ . unwrap_or_else ( |e| e. into_inner ( ) )
113+ . remove ( & counter) ;
114+
115+ // Only call the user's callback when `run` succeeded — a partial profile
116+ // captured against an aborted execution wouldn't be meaningful.
117+ if let ( Some ( profiler) , Ok ( _) ) = ( drained, & result) {
118+ on_profile ( profiler. get_profile ( program) ) ;
119+ }
88120
89121 result
90122 }
91123}
92124
93- /// RAII cleanup for the profiler globals. Restores `*trace_id_ptr` and drops the
94- /// `LIBFUNC_PROFILE` slot at `counter` if it's still occupied.
125+ /// RAII cleanup for the profiler globals. Restores `*trace_id_ptr` on success or
126+ /// unwind. The [`LIBFUNC_PROFILE`] slot at `counter` is normally drained on the
127+ /// success path; this guard removes it if it's still occupied (panic case).
95128struct ProfilerGuard {
96129 trace_id_ptr : * mut u64 ,
97130 old_trace_id : u64 ,
@@ -100,11 +133,15 @@ struct ProfilerGuard {
100133
101134impl Drop for ProfilerGuard {
102135 fn drop ( & mut self ) {
103- // SAFETY: same provenance as the construction site; single-threaded use.
136+ // SAFETY: same provenance as the construction site. `PROFILE_LOCK` is held
137+ // by the enclosing scope (still in flight while we drop) so no other thread
138+ // races us.
104139 unsafe {
105140 * self . trace_id_ptr = self . old_trace_id ;
106141 }
107- // Tolerate a poisoned mutex silently — Drop must not panic.
142+ // Tolerate a poisoned mutex silently — Drop must not panic. Slot leak on
143+ // poison is intentional and matches the behavior of other Drop impls in
144+ // this crate; the alternative (panic in Drop) is worse.
108145 if let Ok ( mut profile) = LIBFUNC_PROFILE . lock ( ) {
109146 profile. remove ( & self . counter ) ;
110147 }
0 commit comments