Skip to main content

blitz_traits/
profiling.rs

1//! Process-wide switch for the intrusive performance collectors.
2//!
3//! Inspection and input control do not need these collectors. They stay dormant
4//! until the embedder explicitly starts a profiling session, so shipping the
5//! capability does not mean continuously paying clocks, locks, maps, or sample
6//! retention.
7
8use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9
10/// Whether this process is *allowed* to sample, not whether it is sampling.
11///
12/// The distinction is the point. This used to be the activation switch, so a
13/// profile that had been enabled once began collecting at boot and kept
14/// collecting for the life of the process, for a reader that in the ordinary
15/// case never attached: the readers are the inspector and `blitz-bench`, and
16/// both are separate processes. Samples that go nowhere have no value and are
17/// not free.
18///
19/// Permission is now separate from activation. Collection runs only while a
20/// consumer holds a [`DeepProfilingGuard`], and this flag decides whether one
21/// can be taken at all.
22static DEEP_PROFILING_PERMITTED: AtomicBool = AtomicBool::new(false);
23
24/// How many consumers currently want samples.
25///
26/// A counter rather than a flag because two consumers must be able to overlap:
27/// the inspector attaching while a benchmark is mid-profile is normal, and the
28/// first one to finish must not stop the second one's collection.
29static DEEP_PROFILING_CONSUMERS: AtomicUsize = AtomicUsize::new(0);
30
31/// The two owner-controlled debug capabilities shared by Blitz embedders.
32///
33/// Socket lifecycle belongs to the embedder (for example izumo
34/// or Chuzz), while the engine profiling decision is common to every stack.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct DebugOptions {
37    pub inspection_and_agent_control: bool,
38    pub deep_intrusive_profiling: bool,
39}
40
41impl DebugOptions {
42    /// Whether the intrusive collectors should run.
43    ///
44    /// This is the deep-profiling switch alone. It was once ANDed with
45    /// `inspection_and_agent_control`, on the reasoning that samples are only
46    /// useful while a plane exists to read them back — but the collectors also
47    /// feed the frame log and phase timings, which need no socket, and the AND
48    /// made the embedder's toggle silently inert with inspection off. Turning
49    /// inspection off and on then lost the setting entirely.
50    ///
51    /// Kept as a named method rather than a field read because embedders call
52    /// it across a version boundary.
53    #[must_use]
54    pub const fn effective_deep_profiling(self) -> bool {
55        self.deep_intrusive_profiling
56    }
57}
58
59/// Permit or forbid intrusive collection in this process.
60///
61/// This is the owner's toggle, and it starts nothing on its own. Turning it off
62/// while consumers are still attached also stops collection, because permission
63/// is withdrawn: [`deep_profiling_enabled`] requires both.
64///
65/// Relaxed ordering is sufficient: this is a performance mode, not a memory
66/// publication boundary. Each whole frame, resolve, or script section observes
67/// either state and the next section observes a newly selected state.
68pub fn set_deep_profiling_permitted(permitted: bool) {
69    DEEP_PROFILING_PERMITTED.store(permitted, Ordering::Relaxed);
70}
71
72/// Whether the owner has allowed sampling, regardless of any consumer.
73#[inline]
74#[must_use]
75pub fn deep_profiling_permitted() -> bool {
76    DEEP_PROFILING_PERMITTED.load(Ordering::Relaxed)
77}
78
79/// Ask for samples, for as long as the returned guard is held.
80///
81/// `None` means the profile does not permit sampling. That check lives here
82/// rather than at each call site, so a consumer cannot start collection by
83/// forgetting it.
84///
85/// The guard is the whole reason this is not a bare `start()`/`stop()` pair: an
86/// early return, a `?`, or a panic on the way out of a profiling request would
87/// otherwise leave the collectors running for the life of the process, which is
88/// the failure this function exists to prevent.
89#[must_use = "sampling stops as soon as the guard is dropped"]
90pub fn begin_deep_profiling() -> Option<DeepProfilingGuard> {
91    if !deep_profiling_permitted() {
92        return None;
93    }
94    DEEP_PROFILING_CONSUMERS.fetch_add(1, Ordering::Relaxed);
95    Some(DeepProfilingGuard { _private: () })
96}
97
98/// How many consumers are currently holding sampling open.
99#[inline]
100#[must_use]
101pub fn deep_profiling_consumers() -> usize {
102    DEEP_PROFILING_CONSUMERS.load(Ordering::Relaxed)
103}
104
105/// Holds intrusive collection open. Sampling stops when the last one drops.
106///
107/// Not constructible except through [`begin_deep_profiling`], so the count can
108/// only be raised by a consumer that passed the permission check, and not
109/// `Clone`, so one guard is one consumer.
110#[derive(Debug)]
111pub struct DeepProfilingGuard {
112    _private: (),
113}
114
115impl Drop for DeepProfilingGuard {
116    fn drop(&mut self) {
117        // Saturating rather than wrapping: a count that underflowed would read
118        // as "a consumer is attached" forever, which is precisely the state
119        // this type exists to make impossible.
120        let _ =
121            DEEP_PROFILING_CONSUMERS.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| {
122                Some(count.saturating_sub(1))
123            });
124    }
125}
126
127/// Whether intrusive collection is active for the next whole measured section.
128///
129/// Both halves are required: the owner permits it, and some consumer is
130/// actually reading. Either alone collects nothing.
131#[inline(always)]
132pub fn deep_profiling_enabled() -> bool {
133    DEEP_PROFILING_PERMITTED.load(Ordering::Relaxed)
134        && DEEP_PROFILING_CONSUMERS.load(Ordering::Relaxed) > 0
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    /// The statics are process-wide, so the tests that move them share one
142    /// lock rather than racing each other under the default parallel harness.
143    static SERIALISE: std::sync::Mutex<()> = std::sync::Mutex::new(());
144
145    fn exclusive() -> std::sync::MutexGuard<'static, ()> {
146        let guard = SERIALISE.lock().unwrap_or_else(|e| e.into_inner());
147        set_deep_profiling_permitted(false);
148        assert_eq!(
149            deep_profiling_consumers(),
150            0,
151            "a previous test leaked a guard",
152        );
153        guard
154    }
155
156    /// Permission alone must collect nothing.
157    ///
158    /// This is the behaviour the whole change is for: the toggle had been the
159    /// activation switch, so a profile enabled once began sampling at boot and
160    /// never stopped, for a reader that was not there.
161    #[test]
162    fn permission_alone_does_not_start_sampling() {
163        let _lock = exclusive();
164
165        set_deep_profiling_permitted(true);
166        assert!(deep_profiling_permitted());
167        assert!(
168            !deep_profiling_enabled(),
169            "sampling must wait for a consumer, not begin with the setting",
170        );
171
172        set_deep_profiling_permitted(false);
173    }
174
175    /// A consumer with no permission gets nothing to hold.
176    #[test]
177    fn a_forbidden_profile_hands_back_no_guard() {
178        let _lock = exclusive();
179
180        assert!(begin_deep_profiling().is_none());
181        assert!(!deep_profiling_enabled());
182    }
183
184    /// Two consumers overlap, and the first to finish does not stop the second.
185    #[test]
186    fn sampling_runs_until_the_last_consumer_drops() {
187        let _lock = exclusive();
188        set_deep_profiling_permitted(true);
189
190        let first = begin_deep_profiling().expect("permitted, so a guard is available");
191        let second = begin_deep_profiling().expect("a second consumer may overlap the first");
192        assert!(deep_profiling_enabled());
193
194        drop(first);
195        assert!(
196            deep_profiling_enabled(),
197            "one consumer finishing must not stop the other's collection",
198        );
199
200        drop(second);
201        assert!(!deep_profiling_enabled(), "the last drop stops sampling");
202        assert_eq!(deep_profiling_consumers(), 0);
203
204        set_deep_profiling_permitted(false);
205    }
206
207    /// A panic on the way out of a profiling request still stops sampling.
208    ///
209    /// The reason this is a guard rather than `start()`/`stop()`: an early
210    /// return or an unwind past a bare `stop()` would leave the collectors
211    /// running for the life of the process.
212    #[test]
213    fn an_unwound_guard_still_stops_sampling() {
214        let _lock = exclusive();
215        set_deep_profiling_permitted(true);
216
217        let panicked = std::panic::catch_unwind(|| {
218            let _guard = begin_deep_profiling().expect("permitted");
219            panic!("a profiling request failed mid-flight");
220        });
221
222        assert!(panicked.is_err(), "the closure above must have panicked");
223        assert_eq!(
224            deep_profiling_consumers(),
225            0,
226            "the guard's drop must have run while unwinding",
227        );
228        assert!(!deep_profiling_enabled());
229
230        set_deep_profiling_permitted(false);
231    }
232
233    /// Withdrawing permission stops collection even while a consumer is held.
234    ///
235    /// The owner's switch is the outer authority: a tool that is still attached
236    /// does not get to keep sampling after the profile forbids it.
237    #[test]
238    fn withdrawing_permission_stops_a_live_consumer() {
239        let _lock = exclusive();
240        set_deep_profiling_permitted(true);
241
242        let guard = begin_deep_profiling().expect("permitted");
243        assert!(deep_profiling_enabled());
244
245        set_deep_profiling_permitted(false);
246        assert!(!deep_profiling_enabled());
247
248        drop(guard);
249        assert_eq!(deep_profiling_consumers(), 0);
250    }
251
252    /// Deep profiling answers for itself. It used to be ANDed with inspection,
253    /// which made the switch inert whenever the inspection plane was off — the
254    /// embedder's toggle appeared to do nothing, and the stored preference was
255    /// then cleared to match, losing it.
256    #[test]
257    fn deep_profiling_is_independent_of_the_inspection_plane() {
258        assert!(!DebugOptions::default().effective_deep_profiling());
259        for inspection in [false, true] {
260            assert!(
261                DebugOptions {
262                    inspection_and_agent_control: inspection,
263                    deep_intrusive_profiling: true,
264                }
265                .effective_deep_profiling(),
266                "deep profiling should follow its own switch (inspection {inspection})"
267            );
268            assert!(
269                !DebugOptions {
270                    inspection_and_agent_control: inspection,
271                    deep_intrusive_profiling: false,
272                }
273                .effective_deep_profiling(),
274                "deep profiling off means off (inspection {inspection})"
275            );
276        }
277    }
278}