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}