Skip to main content

inspect_stubs/
stubs.rs

1// Copyright 2023 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Stubs for tracking unimplemented code paths.
6//!
7//! This crate provides macros and utilities to track stubbed implementations
8//! and surface them in Inspect for diagnostics.
9
10use flyweights::FlyByteStr;
11use fuchsia_inspect::{ArrayProperty, Inspector};
12use fuchsia_sync::Mutex;
13use futures::future::BoxFuture;
14use std::collections::{HashMap, HashSet};
15use std::hash::{Hash, Hasher};
16use std::num::NonZeroU64;
17use std::panic::Location;
18use std::sync::{Arc, LazyLock};
19
20static STUB_COUNTS: LazyLock<Mutex<HashMap<Invocation, Counts>>> =
21    LazyLock::new(|| Mutex::new(HashMap::new()));
22
23static CONTEXT_NAME_CALLBACK: Mutex<Option<Arc<dyn Fn() -> FlyByteStr + Send + Sync>>> =
24    Mutex::new(None);
25
26/// Tracks a stubbed implementation.
27///
28/// This macro records that a stub was encountered so that it may be surfaced in inspect.
29/// The first time a particular stub is encountered, a log message will be emitted.
30///
31/// Example:
32/// ```
33/// track_stub!(TODO("https://fxbug.dev/12345"), "my component is not implemented");
34/// ```
35#[macro_export]
36macro_rules! track_stub {
37    (TODO($bug_url:literal), $message:expr, $flags:expr $(,)?) => {{
38        $crate::__track_stub_inner(
39            $crate::bug_ref!($bug_url),
40            $message,
41            Some($flags.into()),
42            std::panic::Location::caller(),
43        );
44    }};
45    (TODO($bug_url:literal), $message:expr $(,)?) => {{
46        $crate::__track_stub_inner(
47            $crate::bug_ref!($bug_url),
48            $message,
49            None,
50            std::panic::Location::caller(),
51        );
52    }};
53}
54
55/// Tracks a stubbed implementation with a specified log level.
56///
57/// This macro records that a stub was encountered so that it may be surfaced in inspect.
58/// The first time a particular stub is encountered, a log message will be emitted at the
59/// specified level.
60///
61/// Example:
62/// ```
63/// track_stub_log!(log::Level::Warn, TODO("https://fxbug.dev/12345"), "my component is not implemented");
64/// ```
65#[macro_export]
66macro_rules! track_stub_log {
67    ($level:expr, TODO($bug_url:literal), $message:expr, $flags:expr $(,)?) => {{
68        $crate::__track_stub_inner_with_level(
69            $level,
70            $crate::bug_ref!($bug_url),
71            $message,
72            Some($flags.into()),
73            std::panic::Location::caller(),
74        );
75    }};
76    ($level:expr, TODO($bug_url:literal), $message:expr $(,)?) => {{
77        $crate::__track_stub_inner_with_level(
78            $level,
79            $crate::bug_ref!($bug_url),
80            $message,
81            None,
82            std::panic::Location::caller(),
83        );
84    }};
85}
86
87// This is the struct we'll actually store in the HashMap of
88// invocations. It needs to contain an owned String for lifetime
89// purposes.
90#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
91struct Invocation {
92    location: &'static Location<'static>,
93    message: String,
94    bug: BugRef,
95}
96
97// This trait allows us to look up in the invocation HashMap with
98// either a borrowed message or an owned message.
99trait InvocationLookup {
100    fn location(&self) -> &'static Location<'static>;
101    fn message(&self) -> &str;
102    fn bug(&self) -> BugRef;
103}
104
105impl Hash for dyn InvocationLookup + '_ {
106    fn hash<H: Hasher>(&self, state: &mut H) {
107        self.location().hash(state);
108        self.message().hash(state);
109        self.bug().hash(state);
110    }
111}
112
113impl PartialEq for dyn InvocationLookup + '_ {
114    fn eq(&self, other: &Self) -> bool {
115        self.location() == other.location()
116            && self.message() == other.message()
117            && self.bug() == other.bug()
118    }
119}
120
121impl Eq for dyn InvocationLookup + '_ {}
122
123impl InvocationLookup for Invocation {
124    fn location(&self) -> &'static Location<'static> {
125        self.location
126    }
127
128    fn message(&self) -> &str {
129        &self.message
130    }
131
132    fn bug(&self) -> BugRef {
133        self.bug
134    }
135}
136
137impl<'a> std::borrow::Borrow<dyn InvocationLookup + 'a> for Invocation {
138    fn borrow(&self) -> &(dyn InvocationLookup + 'a) {
139        self
140    }
141}
142
143// This struct is never to be stored, but is constructed for lookup
144// purposes on the invocations map. Looking up using a borrowed string
145// for 'message' saves an allocation if the key is already in the map,
146// which could be significant if a client is opening a stub file very
147// frequently.
148struct InvocationKey<'a> {
149    location: &'static Location<'static>,
150    message: &'a str,
151    bug: BugRef,
152}
153
154impl<'a> InvocationLookup for InvocationKey<'a> {
155    fn location(&self) -> &'static Location<'static> {
156        self.location
157    }
158
159    fn message(&self) -> &str {
160        self.message
161    }
162
163    fn bug(&self) -> BugRef {
164        self.bug
165    }
166}
167
168#[derive(Default)]
169struct Counts {
170    by_flags: HashMap<Option<u64>, u64>,
171    contexts_seen: HashSet<FlyByteStr>,
172}
173
174#[doc(hidden)]
175#[inline]
176pub fn __track_stub_inner(
177    bug: BugRef,
178    message: &str,
179    flags: Option<u64>,
180    location: &'static Location<'static>,
181) -> u64 {
182    __track_stub_inner_with_level(log::Level::Debug, bug, message, flags, location)
183}
184
185#[doc(hidden)]
186#[inline]
187pub fn __track_stub_inner_with_level(
188    level: log::Level,
189    bug: BugRef,
190    message: &str,
191    flags: Option<u64>,
192    location: &'static Location<'static>,
193) -> u64 {
194    let current_context = {
195        let cb = CONTEXT_NAME_CALLBACK.lock().clone();
196        cb.as_ref().map(|cb| cb())
197    };
198
199    let mut counts = STUB_COUNTS.lock();
200    let key = InvocationKey { location, message, bug };
201
202    if let Some(message_counts) = counts.get_mut(&key as &dyn InvocationLookup) {
203        let context_count = message_counts.by_flags.entry(flags).or_default();
204        if let Some(ref current_context) = current_context {
205            message_counts.contexts_seen.insert(current_context.clone());
206        }
207        if *context_count == 0 {
208            match flags {
209                Some(flags) => {
210                    log::log!(level, tag = "track_stub", location:%; "{bug} {message}: 0x{flags:x}");
211                }
212                None => {
213                    log::log!(level, tag = "track_stub", location:%; "{bug} {message}");
214                }
215            }
216        }
217        *context_count += 1;
218        return *context_count;
219    }
220
221    match flags {
222        Some(flags) => {
223            log::log!(level, tag = "track_stub", location:%; "{bug} {message}: 0x{flags:x}");
224        }
225        None => {
226            log::log!(level, tag = "track_stub", location:%; "{bug} {message}");
227        }
228    }
229
230    let mut message_counts = Counts::default();
231    if let Some(current_context) = current_context {
232        message_counts.contexts_seen.insert(current_context);
233    }
234    message_counts.by_flags.insert(flags, 1);
235    counts.insert(Invocation { location, message: String::from(message), bug }, message_counts);
236    1
237}
238
239/// Provide a callback to retrieve the current context name, for example the name of the current
240/// Starnix process.
241pub fn register_context_name_callback(cb: impl Fn() -> FlyByteStr + Send + Sync + 'static) {
242    *CONTEXT_NAME_CALLBACK.lock() = Some(Arc::new(cb));
243}
244
245/// Returns a future that resolves to an `Inspector` containing stub information.
246///
247/// This function can be used to create a lazy node in inspect that exposes the locations
248/// where stubs have been tracked.
249pub fn track_stub_lazy_node_callback() -> BoxFuture<'static, Result<Inspector, anyhow::Error>> {
250    Box::pin(async {
251        let inspector = Inspector::default();
252        for (Invocation { location, message, bug }, context_counts) in STUB_COUNTS.lock().iter() {
253            inspector.root().atomic_update(|root| {
254                root.record_child(message, |message_node| {
255                    message_node.record_string("file", location.file());
256                    message_node.record_uint("line", location.line().into());
257                    message_node.record_string("bug", bug.to_string());
258
259                    if !context_counts.contexts_seen.is_empty() {
260                        let mut contexts =
261                            context_counts.contexts_seen.iter().cloned().collect::<Vec<_>>();
262                        contexts.sort();
263                        let contexts_prop =
264                            message_node.create_string_array("contexts", contexts.len());
265                        for (i, context) in contexts.iter().enumerate() {
266                            contexts_prop.set(i, context.to_string());
267                        }
268                        message_node.record(contexts_prop);
269                    }
270
271                    // Make a copy of the map so we can mutate it while recording values.
272                    let mut context_counts = context_counts.by_flags.clone();
273
274                    if let Some(no_context_count) = context_counts.remove(&None) {
275                        // If the track_stub callsite doesn't provide any context,
276                        // record the count as a property on the node without an intermediate.
277                        message_node.record_uint("count", no_context_count);
278                    }
279
280                    if !context_counts.is_empty() {
281                        message_node.record_child("counts", |counts_node| {
282                            for (context, count) in context_counts {
283                                if let Some(c) = context {
284                                    counts_node.record_uint(format!("0x{c:x}"), count);
285                                }
286                            }
287                        });
288                    }
289                });
290            });
291        }
292        Ok(inspector)
293    })
294}
295
296/// Creates a `BugRef` from a URL literal.
297///
298/// This macro will cause a compilation error if the provided literal is not a valid Fuchsia bug URL.
299#[macro_export]
300macro_rules! bug_ref {
301    ($bug_url:literal) => {{
302        // Assign the value to a const to ensure we get compile-time validation of the URL.
303        const __REF: $crate::BugRef = match $crate::BugRef::from_str($bug_url) {
304            Some(b) => b,
305            None => panic!("bug references must have the form `https://fxbug.dev/123456789`"),
306        };
307        __REF
308    }};
309}
310
311/// Represents a reference to a Fuchsia bug.
312///
313/// This struct is used to ensure that stubs are tracked against a valid bug.
314#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
315pub struct BugRef {
316    number: u64,
317}
318
319impl BugRef {
320    #[doc(hidden)] // use bug_ref!() instead
321    pub const fn from_str(url: &'static str) -> Option<Self> {
322        let expected_prefix = b"https://fxbug.dev/";
323        let url = str::as_bytes(url);
324
325        if url.len() < expected_prefix.len() {
326            return None;
327        }
328        let (scheme_and_domain, number_str) = url.split_at(expected_prefix.len());
329        if number_str.is_empty() {
330            return None;
331        }
332
333        // The standard library doesn't seem to have a const string or slice equality function.
334        {
335            let mut i = 0;
336            while i < scheme_and_domain.len() {
337                if scheme_and_domain[i] != expected_prefix[i] {
338                    return None;
339                }
340                i += 1;
341            }
342        }
343
344        // The standard library doesn't seem to have a const base 10 string parser.
345        let mut number = 0;
346        {
347            let mut i = 0;
348            while i < number_str.len() {
349                number *= 10;
350                number += match number_str[i] {
351                    b'0' => 0,
352                    b'1' => 1,
353                    b'2' => 2,
354                    b'3' => 3,
355                    b'4' => 4,
356                    b'5' => 5,
357                    b'6' => 6,
358                    b'7' => 7,
359                    b'8' => 8,
360                    b'9' => 9,
361                    _ => return None,
362                };
363                i += 1;
364            }
365        }
366
367        if number != 0 { Some(Self { number }) } else { None }
368    }
369}
370
371impl From<NonZeroU64> for BugRef {
372    /// Converts a `NonZeroU64` into a `BugRef`.
373    fn from(value: NonZeroU64) -> Self {
374        Self { number: value.get() }
375    }
376}
377
378impl Into<NonZeroU64> for BugRef {
379    /// Converts a `BugRef` into a `NonZeroU64`.
380    fn into(self) -> NonZeroU64 {
381        NonZeroU64::new(self.number).unwrap()
382    }
383}
384
385impl std::fmt::Display for BugRef {
386    /// Formats the `BugRef` as a URL string.
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        write!(f, "https://fxbug.dev/{}", self.number)
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use diagnostics_assertions::assert_data_tree;
396
397    #[test]
398    fn valid_url_parses() {
399        assert_eq!(BugRef::from_str("https://fxbug.dev/1234567890").unwrap().number, 1234567890);
400    }
401
402    #[test]
403    fn missing_prefix_fails() {
404        assert_eq!(BugRef::from_str("1234567890"), None);
405    }
406
407    #[test]
408    fn missing_number_fails() {
409        assert_eq!(BugRef::from_str("https://fxbug.dev/"), None);
410    }
411
412    #[test]
413    fn short_prefixes_fail() {
414        assert_eq!(BugRef::from_str("b/1234567890"), None);
415        assert_eq!(BugRef::from_str("fxb/1234567890"), None);
416        assert_eq!(BugRef::from_str("fxbug.dev/1234567890"), None);
417    }
418
419    #[test]
420    fn invalid_characters_fail() {
421        assert_eq!(BugRef::from_str("https://fxbug.dev/123a45"), None);
422    }
423
424    #[test]
425    fn zero_bug_number_fails() {
426        assert_eq!(BugRef::from_str("https://fxbug.dev/0"), None);
427    }
428
429    #[fuchsia::test]
430    async fn test_track_stub() {
431        let inspector = Inspector::default();
432        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
433
434        let call_stub = || {
435            track_stub!(TODO("https://fxbug.dev/1"), "test stub");
436            std::line!() as u64 - 1
437        };
438
439        let file = std::panic::Location::caller().file();
440        let line = call_stub();
441
442        assert_data_tree!(inspector, root: {
443            stubs: {
444                "test stub": {
445                    bug: "https://fxbug.dev/1",
446                    count: 1u64,
447                    file: file,
448                    line: line,
449                }
450            }
451        });
452
453        call_stub();
454        assert_data_tree!(inspector, root: {
455            stubs: {
456                "test stub": {
457                    bug: "https://fxbug.dev/1",
458                    count: 2u64,
459                    file: file,
460                    line: line,
461                }
462            }
463        });
464    }
465
466    #[fuchsia::test]
467    async fn test_track_stub_different_callsites() {
468        let inspector = Inspector::default();
469        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
470
471        let loc1 = std::panic::Location::caller();
472        track_stub!(TODO("https://fxbug.dev/1"), "stub 1");
473        let loc2 = std::panic::Location::caller();
474        track_stub!(TODO("https://fxbug.dev/2"), "stub 2");
475
476        assert_data_tree!(inspector, root: {
477            stubs: {
478                "stub 1": {
479                    bug: "https://fxbug.dev/1",
480                    count: 1u64,
481                    file: loc1.file(),
482                    line: (loc1.line() + 1) as u64,
483                },
484                "stub 2": {
485                    bug: "https://fxbug.dev/2",
486                    count: 1u64,
487                    file: loc2.file(),
488                    line: (loc2.line() + 1) as u64,
489                }
490            }
491        });
492    }
493
494    #[fuchsia::test]
495    async fn test_track_stub_with_flags() {
496        let inspector = Inspector::default();
497        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
498
499        let call_stub = |flags: u64| {
500            track_stub!(TODO("https://fxbug.dev/3"), "stub with flags", flags);
501            std::line!() - 1
502        };
503
504        let file = std::panic::Location::caller().file();
505        let line = call_stub(0x1);
506        call_stub(0x2);
507        call_stub(0x1);
508
509        assert_data_tree!(inspector, root: {
510            stubs: {
511                "stub with flags": {
512                    bug: "https://fxbug.dev/3",
513                    file: file,
514                    line: line as u64,
515                    counts: {
516                        "0x1": 2u64,
517                        "0x2": 1u64,
518                    }
519                }
520            }
521        });
522    }
523
524    #[fuchsia::test]
525    async fn test_track_stub_with_context() {
526        let inspector = Inspector::default();
527        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
528
529        let current_context = std::sync::Arc::new(Mutex::new("SHOULD NOT SHOW UP"));
530        let context_clone = current_context.clone();
531        register_context_name_callback(move || FlyByteStr::from(*context_clone.lock()));
532
533        let call_stub_with_context = |context| {
534            *current_context.lock() = context;
535            track_stub!(TODO("https://fxbug.dev/4"), "stub with context");
536        };
537        let line = std::line!() as u64 - 2;
538
539        call_stub_with_context("context1");
540        call_stub_with_context("context2");
541
542        assert_data_tree!(inspector, root: {
543            stubs: {
544                "stub with context": {
545                    bug: "https://fxbug.dev/4",
546                    count: 2u64,
547                    file: std::file!(),
548                    line: line,
549                    contexts: vec!["context1", "context2"]
550                }
551            }
552        });
553    }
554
555    #[fuchsia::test]
556    async fn test_track_stub_reentrant_context_callback() {
557        let entered = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
558        let entered_clone = entered.clone();
559        register_context_name_callback(move || {
560            if !entered_clone.swap(true, std::sync::atomic::Ordering::SeqCst) {
561                track_stub!(TODO("https://fxbug.dev/9991"), "nested stub");
562            }
563            FlyByteStr::from("reentrant_ctx")
564        });
565
566        track_stub!(TODO("https://fxbug.dev/9992"), "outer stub");
567    }
568}