Skip to main content

hopper_runtime/
log.rs

1//! Hopper logging helpers.
2//!
3//! Two tiers are exposed:
4//!
5//! - [`log`] for arbitrary UTF-8 text through the active backend's
6//!   `sol_log_` syscall.
7//! - [`log_64`] for integer-heavy logs through the five-u64 `sol_log_64_`
8//!   syscall, which is the cheapest structured-log path on Solana. This
9//!   backs the `hopper_log!` macro's "label + values" form and lets
10//!   hot handlers emit telemetry without the `core::fmt::Write` setup
11//!   cost that `msg!` pays.
12
13/// Log a UTF-8 message through Hopper's direct runtime.
14#[inline(always)]
15pub fn log(message: &str) {
16    #[cfg(target_os = "solana")]
17    // SAFETY: The pointer and the length come from one live slice, which
18    // outlives the synchronous syscall.
19    unsafe {
20        hopper_native::syscalls::sol_log_(message.as_ptr(), message.len() as u64);
21    }
22
23    #[cfg(not(target_os = "solana"))]
24    {
25        let _ = message;
26    }
27}
28
29/// Log up to five `u64` values through the `sol_log_64_` syscall.
30///
31/// One syscall, no allocation, no format parsing. Pad unused slots
32/// with zero. The Solana runtime renders the five values as a single
33/// line "Program log: 0x... 0x... ...". Use this as the tight-loop
34/// escape hatch when the output is going to be grep'd, not read.
35///
36/// ```ignore
37/// // Emit "balance, delta, new_balance":
38/// hopper_runtime::log::log_64(balance, delta, new_balance, 0, 0);
39/// ```
40#[inline(always)]
41pub fn log_64(a: u64, b: u64, c: u64, d: u64, e: u64) {
42    #[cfg(target_os = "solana")]
43    // SAFETY: The syscall takes no pointer and has no memory precondition.
44    unsafe {
45        hopper_native::syscalls::sol_log_64_(a, b, c, d, e);
46    }
47
48    #[cfg(not(target_os = "solana"))]
49    {
50        let _ = (a, b, c, d, e);
51    }
52}
53
54/// Stack-allocated write buffer for formatted log messages.
55pub struct StackWriter<'a> {
56    buf: &'a mut [u8],
57    pos: usize,
58    truncated: bool,
59}
60
61impl<'a> StackWriter<'a> {
62    /// Create a new writer over the given buffer.
63    #[inline(always)]
64    pub fn new(buf: &'a mut [u8]) -> Self {
65        Self {
66            buf,
67            pos: 0,
68            truncated: false,
69        }
70    }
71
72    /// Number of bytes written.
73    #[inline(always)]
74    pub fn pos(&self) -> usize {
75        self.pos
76    }
77
78    /// Whether the message did not fit and was cut.
79    #[inline(always)]
80    pub fn truncated(&self) -> bool {
81        self.truncated
82    }
83
84    /// The text written so far. A message that did not fit ends at the
85    /// last whole character that did.
86    #[inline(always)]
87    pub fn as_str(&self) -> &str {
88        // SAFETY: every byte in `buf[..pos]` was copied from a `&str` by
89        // `write_str`, which copies whole strings or cuts on a character
90        // boundary and then accepts nothing more, so the prefix is a
91        // concatenation of valid UTF-8 strings.
92        unsafe { core::str::from_utf8_unchecked(&self.buf[..self.pos]) }
93    }
94}
95
96impl core::fmt::Write for StackWriter<'_> {
97    fn write_str(&mut self, s: &str) -> core::fmt::Result {
98        // After a cut nothing more is accepted: text appended behind a
99        // dropped tail would read as if nothing were missing.
100        if self.truncated {
101            return Ok(());
102        }
103        let remaining = self.buf.len().saturating_sub(self.pos);
104        let mut to_write = s.len();
105        if to_write > remaining {
106            // Cut on a character boundary: the log syscall refuses bytes
107            // that are not UTF-8 and fails the transaction.
108            to_write = remaining;
109            while !s.is_char_boundary(to_write) {
110                to_write -= 1;
111            }
112            self.truncated = true;
113        }
114        self.buf[self.pos..self.pos + to_write].copy_from_slice(&s.as_bytes()[..to_write]);
115        self.pos += to_write;
116        Ok(())
117    }
118}
119
120#[cfg(test)]
121mod stack_writer_tests {
122    use super::StackWriter;
123    use core::fmt::Write;
124
125    #[test]
126    fn a_message_that_fits_is_written_whole() {
127        let mut buf = [0u8; 16];
128        let mut writer = StackWriter::new(&mut buf);
129        write!(writer, "slot {}", 42).unwrap();
130        assert_eq!(writer.as_str(), "slot 42");
131        assert!(!writer.truncated());
132    }
133
134    #[test]
135    fn a_cut_never_splits_a_character() {
136        // Every buffer length against text with 1, 2, 3 and 4 byte
137        // characters: the result is always a prefix made of whole
138        // characters, and the longest one that fits.
139        let text = "a\u{e9}\u{20ac}\u{1f980}z\u{e9}\u{1f980}";
140        for len in 0..=text.len() + 2 {
141            let mut buf = [0xffu8; 32];
142            let mut writer = StackWriter::new(&mut buf[..len]);
143            write!(writer, "{text}").unwrap();
144            let written = writer.as_str();
145            assert!(text.starts_with(written), "len {len}");
146            assert!(core::str::from_utf8(written.as_bytes()).is_ok());
147            let next = text[written.len()..].chars().next();
148            match next {
149                Some(c) => {
150                    assert!(writer.truncated());
151                    assert!(
152                        written.len() + c.len_utf8() > len,
153                        "len {len}: room was left"
154                    );
155                }
156                None => assert!(!writer.truncated()),
157            }
158        }
159    }
160
161    #[test]
162    fn nothing_is_appended_after_a_cut() {
163        let mut buf = [0u8; 4];
164        let mut writer = StackWriter::new(&mut buf);
165        // The second argument would fit in the byte the first one left.
166        let (head, tail) = ("ab\u{20ac}", "c");
167        write!(writer, "{head}{tail}").unwrap();
168        assert_eq!(writer.as_str(), "ab");
169        assert!(writer.truncated());
170    }
171}