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