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::LazyLock;
19
20static STUB_COUNTS: LazyLock<Mutex<HashMap<Invocation, Counts>>> =
21    LazyLock::new(|| Mutex::new(HashMap::new()));
22
23static CONTEXT_NAME_CALLBACK: Mutex<Option<Box<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 mut counts = STUB_COUNTS.lock();
195    let key = InvocationKey { location, message, bug };
196
197    if let Some(message_counts) = counts.get_mut(&key as &dyn InvocationLookup) {
198        let context_count = message_counts.by_flags.entry(flags).or_default();
199        if let Some(current_context) = CONTEXT_NAME_CALLBACK.lock().as_ref().map(|cb| cb()) {
200            message_counts.contexts_seen.insert(current_context);
201        }
202        if *context_count == 0 {
203            match flags {
204                Some(flags) => {
205                    log::log!(level, tag = "track_stub", location:%; "{bug} {message}: 0x{flags:x}");
206                }
207                None => {
208                    log::log!(level, tag = "track_stub", location:%; "{bug} {message}");
209                }
210            }
211        }
212        *context_count += 1;
213        return *context_count;
214    }
215
216    match flags {
217        Some(flags) => {
218            log::log!(level, tag = "track_stub", location:%; "{bug} {message}: 0x{flags:x}");
219        }
220        None => {
221            log::log!(level, tag = "track_stub", location:%; "{bug} {message}");
222        }
223    }
224
225    let mut message_counts = Counts::default();
226    if let Some(current_context) = CONTEXT_NAME_CALLBACK.lock().as_ref().map(|cb| cb()) {
227        message_counts.contexts_seen.insert(current_context);
228    }
229    message_counts.by_flags.insert(flags, 1);
230    counts.insert(Invocation { location, message: String::from(message), bug }, message_counts);
231    1
232}
233
234/// Provide a callback to retrieve the current context name, for example the name of the current
235/// Starnix process.
236pub fn register_context_name_callback(cb: impl Fn() -> FlyByteStr + Send + Sync + 'static) {
237    *CONTEXT_NAME_CALLBACK.lock() = Some(Box::new(cb));
238}
239
240/// Returns a future that resolves to an `Inspector` containing stub information.
241///
242/// This function can be used to create a lazy node in inspect that exposes the locations
243/// where stubs have been tracked.
244pub fn track_stub_lazy_node_callback() -> BoxFuture<'static, Result<Inspector, anyhow::Error>> {
245    Box::pin(async {
246        let inspector = Inspector::default();
247        for (Invocation { location, message, bug }, context_counts) in STUB_COUNTS.lock().iter() {
248            inspector.root().atomic_update(|root| {
249                root.record_child(message, |message_node| {
250                    message_node.record_string("file", location.file());
251                    message_node.record_uint("line", location.line().into());
252                    message_node.record_string("bug", bug.to_string());
253
254                    if !context_counts.contexts_seen.is_empty() {
255                        let mut contexts =
256                            context_counts.contexts_seen.iter().cloned().collect::<Vec<_>>();
257                        contexts.sort();
258                        let contexts_prop =
259                            message_node.create_string_array("contexts", contexts.len());
260                        for (i, context) in contexts.iter().enumerate() {
261                            contexts_prop.set(i, context.to_string());
262                        }
263                        message_node.record(contexts_prop);
264                    }
265
266                    // Make a copy of the map so we can mutate it while recording values.
267                    let mut context_counts = context_counts.by_flags.clone();
268
269                    if let Some(no_context_count) = context_counts.remove(&None) {
270                        // If the track_stub callsite doesn't provide any context,
271                        // record the count as a property on the node without an intermediate.
272                        message_node.record_uint("count", no_context_count);
273                    }
274
275                    if !context_counts.is_empty() {
276                        message_node.record_child("counts", |counts_node| {
277                            for (context, count) in context_counts {
278                                if let Some(c) = context {
279                                    counts_node.record_uint(format!("0x{c:x}"), count);
280                                }
281                            }
282                        });
283                    }
284                });
285            });
286        }
287        Ok(inspector)
288    })
289}
290
291/// Creates a `BugRef` from a URL literal.
292///
293/// This macro will cause a compilation error if the provided literal is not a valid Fuchsia bug URL.
294#[macro_export]
295macro_rules! bug_ref {
296    ($bug_url:literal) => {{
297        // Assign the value to a const to ensure we get compile-time validation of the URL.
298        const __REF: $crate::BugRef = match $crate::BugRef::from_str($bug_url) {
299            Some(b) => b,
300            None => panic!("bug references must have the form `https://fxbug.dev/123456789`"),
301        };
302        __REF
303    }};
304}
305
306/// Represents a reference to a Fuchsia bug.
307///
308/// This struct is used to ensure that stubs are tracked against a valid bug.
309#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
310pub struct BugRef {
311    number: u64,
312}
313
314impl BugRef {
315    #[doc(hidden)] // use bug_ref!() instead
316    pub const fn from_str(url: &'static str) -> Option<Self> {
317        let expected_prefix = b"https://fxbug.dev/";
318        let url = str::as_bytes(url);
319
320        if url.len() < expected_prefix.len() {
321            return None;
322        }
323        let (scheme_and_domain, number_str) = url.split_at(expected_prefix.len());
324        if number_str.is_empty() {
325            return None;
326        }
327
328        // The standard library doesn't seem to have a const string or slice equality function.
329        {
330            let mut i = 0;
331            while i < scheme_and_domain.len() {
332                if scheme_and_domain[i] != expected_prefix[i] {
333                    return None;
334                }
335                i += 1;
336            }
337        }
338
339        // The standard library doesn't seem to have a const base 10 string parser.
340        let mut number = 0;
341        {
342            let mut i = 0;
343            while i < number_str.len() {
344                number *= 10;
345                number += match number_str[i] {
346                    b'0' => 0,
347                    b'1' => 1,
348                    b'2' => 2,
349                    b'3' => 3,
350                    b'4' => 4,
351                    b'5' => 5,
352                    b'6' => 6,
353                    b'7' => 7,
354                    b'8' => 8,
355                    b'9' => 9,
356                    _ => return None,
357                };
358                i += 1;
359            }
360        }
361
362        if number != 0 { Some(Self { number }) } else { None }
363    }
364}
365
366impl From<NonZeroU64> for BugRef {
367    /// Converts a `NonZeroU64` into a `BugRef`.
368    fn from(value: NonZeroU64) -> Self {
369        Self { number: value.get() }
370    }
371}
372
373impl Into<NonZeroU64> for BugRef {
374    /// Converts a `BugRef` into a `NonZeroU64`.
375    fn into(self) -> NonZeroU64 {
376        NonZeroU64::new(self.number).unwrap()
377    }
378}
379
380impl std::fmt::Display for BugRef {
381    /// Formats the `BugRef` as a URL string.
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        write!(f, "https://fxbug.dev/{}", self.number)
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use diagnostics_assertions::assert_data_tree;
391
392    #[test]
393    fn valid_url_parses() {
394        assert_eq!(BugRef::from_str("https://fxbug.dev/1234567890").unwrap().number, 1234567890);
395    }
396
397    #[test]
398    fn missing_prefix_fails() {
399        assert_eq!(BugRef::from_str("1234567890"), None);
400    }
401
402    #[test]
403    fn missing_number_fails() {
404        assert_eq!(BugRef::from_str("https://fxbug.dev/"), None);
405    }
406
407    #[test]
408    fn short_prefixes_fail() {
409        assert_eq!(BugRef::from_str("b/1234567890"), None);
410        assert_eq!(BugRef::from_str("fxb/1234567890"), None);
411        assert_eq!(BugRef::from_str("fxbug.dev/1234567890"), None);
412    }
413
414    #[test]
415    fn invalid_characters_fail() {
416        assert_eq!(BugRef::from_str("https://fxbug.dev/123a45"), None);
417    }
418
419    #[test]
420    fn zero_bug_number_fails() {
421        assert_eq!(BugRef::from_str("https://fxbug.dev/0"), None);
422    }
423
424    #[fuchsia::test]
425    async fn test_track_stub() {
426        let inspector = Inspector::default();
427        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
428
429        let call_stub = || {
430            track_stub!(TODO("https://fxbug.dev/1"), "test stub");
431            std::line!() as u64 - 1
432        };
433
434        let file = std::panic::Location::caller().file();
435        let line = call_stub();
436
437        assert_data_tree!(inspector, root: {
438            stubs: {
439                "test stub": {
440                    bug: "https://fxbug.dev/1",
441                    count: 1u64,
442                    file: file,
443                    line: line,
444                }
445            }
446        });
447
448        call_stub();
449        assert_data_tree!(inspector, root: {
450            stubs: {
451                "test stub": {
452                    bug: "https://fxbug.dev/1",
453                    count: 2u64,
454                    file: file,
455                    line: line,
456                }
457            }
458        });
459    }
460
461    #[fuchsia::test]
462    async fn test_track_stub_different_callsites() {
463        let inspector = Inspector::default();
464        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
465
466        let loc1 = std::panic::Location::caller();
467        track_stub!(TODO("https://fxbug.dev/1"), "stub 1");
468        let loc2 = std::panic::Location::caller();
469        track_stub!(TODO("https://fxbug.dev/2"), "stub 2");
470
471        assert_data_tree!(inspector, root: {
472            stubs: {
473                "stub 1": {
474                    bug: "https://fxbug.dev/1",
475                    count: 1u64,
476                    file: loc1.file(),
477                    line: (loc1.line() + 1) as u64,
478                },
479                "stub 2": {
480                    bug: "https://fxbug.dev/2",
481                    count: 1u64,
482                    file: loc2.file(),
483                    line: (loc2.line() + 1) as u64,
484                }
485            }
486        });
487    }
488
489    #[fuchsia::test]
490    async fn test_track_stub_with_flags() {
491        let inspector = Inspector::default();
492        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
493
494        let call_stub = |flags: u64| {
495            track_stub!(TODO("https://fxbug.dev/3"), "stub with flags", flags);
496            std::line!() - 1
497        };
498
499        let file = std::panic::Location::caller().file();
500        let line = call_stub(0x1);
501        call_stub(0x2);
502        call_stub(0x1);
503
504        assert_data_tree!(inspector, root: {
505            stubs: {
506                "stub with flags": {
507                    bug: "https://fxbug.dev/3",
508                    file: file,
509                    line: line as u64,
510                    counts: {
511                        "0x1": 2u64,
512                        "0x2": 1u64,
513                    }
514                }
515            }
516        });
517    }
518
519    #[fuchsia::test]
520    async fn test_track_stub_with_context() {
521        let inspector = Inspector::default();
522        inspector.root().record_lazy_child("stubs", track_stub_lazy_node_callback);
523
524        let current_context = std::sync::Arc::new(Mutex::new("SHOULD NOT SHOW UP"));
525        let context_clone = current_context.clone();
526        register_context_name_callback(move || FlyByteStr::from(*context_clone.lock()));
527
528        let call_stub_with_context = |context| {
529            *current_context.lock() = context;
530            track_stub!(TODO("https://fxbug.dev/4"), "stub with context");
531        };
532        let line = std::line!() as u64 - 2;
533
534        call_stub_with_context("context1");
535        call_stub_with_context("context2");
536
537        assert_data_tree!(inspector, root: {
538            stubs: {
539                "stub with context": {
540                    bug: "https://fxbug.dev/4",
541                    count: 2u64,
542                    file: std::file!(),
543                    line: line,
544                    contexts: vec!["context1", "context2"]
545                }
546            }
547        });
548    }
549}