1#![expect(
5 clippy::cast_possible_truncation,
6 reason = "profiler JSON indices and address tables are bounded by in-memory collection sizes"
7)]
8
9use std::{
10 io::Read,
11 process::{Command, Stdio},
12};
13
14use tokio::{
15 io::{AsyncBufReadExt, BufReader},
16 process::Command as TokioCommand,
17};
18
19const ADDR2LINE_LINES_PER_ADDR: usize = 2;
21const ATOS_IN_PREFIX_LEN: usize = " (in ".len();
22const HEX_RADIX: u32 = 16;
23
24use anyhow::{Context as _, anyhow};
25use flate2::read::GzDecoder;
26use wowlab_common::output;
27use wowlab_fs::{
28 directory::{self, EntryKind},
29 file,
30 path::{Path, PathBuf},
31};
32use wowlab_types::sim::{FastMap, FastSet};
33
34#[derive(Debug)]
35pub(super) struct RawProfile {
36 pub samples: Vec<StackSample>,
37 pub strings: Vec<String>,
38 pub total_weight: u64,
39}
40
41#[derive(Debug)]
42pub(super) struct StackSample {
43 pub frames: Vec<usize>,
44 pub weight: u64,
45}
46
47#[derive(Debug, thiserror::Error)]
48#[error("{cause}{details}", details = SamplyStderr(stderr))]
49pub(super) struct RecordError {
50 pub cause: String,
51 pub stderr: Vec<String>,
52}
53
54struct SamplyStderr<'a>(&'a [String]);
55
56impl std::fmt::Display for SamplyStderr<'_> {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 if !self.0.is_empty() {
59 write!(f, "\n--- samply stderr ---")?;
60
61 for line in self.0 {
62 write!(f, "\n{line}")?;
63 }
64 }
65
66 Ok(())
67 }
68}
69
70pub(super) fn record(binary: &Path, args: &[String], output: &Path) -> Result<(), RecordError> {
71 let runtime = tokio::runtime::Builder::new_current_thread()
72 .enable_io()
73 .build()
74 .map_err(|e| RecordError {
75 cause: format!("failed to build tokio runtime: {e}"),
76 stderr: Vec::new(),
77 })?;
78
79 runtime.block_on(record_async(binary, args, output))
80}
81
82async fn record_async(binary: &Path, args: &[String], output: &Path) -> Result<(), RecordError> {
83 let mut child = TokioCommand::new("samply")
84 .arg("record")
85 .arg("-s")
86 .arg("-o")
87 .arg(output.as_os_str())
88 .arg(binary.as_os_str())
89 .args(args)
90 .env("RUST_LOG", "error")
91 .stdout(Stdio::null())
92 .stderr(Stdio::piped())
93 .spawn()
94 .map_err(|e| RecordError {
95 cause: format!("failed to run samply: {e}"),
96 stderr: Vec::new(),
97 })?;
98
99 let stderr_pipe = child.stderr.take().ok_or_else(|| RecordError {
100 cause: "samply child has no stderr pipe".to_string(),
101 stderr: Vec::new(),
102 })?;
103
104 let tee_handle = tokio::spawn(async move {
105 let mut buffer: Vec<String> = Vec::new();
106 let mut lines = BufReader::new(stderr_pipe).lines();
107
108 while let Ok(Some(line)) = lines.next_line().await {
109 eprintln!("{line}");
110 buffer.push(line);
111 tokio::task::yield_now().await;
112 }
113
114 buffer
115 });
116
117 let status = child.wait().await.map_err(|e| RecordError {
118 cause: format!("failed to wait on samply: {e}"),
119 stderr: Vec::new(),
120 })?;
121
122 let stderr_buf = tee_handle.await.unwrap_or_default();
123
124 if !status.success() {
125 return Err(RecordError {
126 cause: "samply exited with non-zero status".to_string(),
127 stderr: stderr_buf,
128 });
129 }
130
131 match directory::inspect(output) {
132 Ok(Some(entry)) if entry.kind() == EntryKind::File => {}
133 Ok(Some(_)) => {
134 return Err(RecordError {
135 cause: format!("profile output is not a regular file: {}", output.display()),
136 stderr: stderr_buf,
137 });
138 }
139 Ok(None) => {
140 return Err(RecordError {
141 cause: format!("profile not found at {}", output.display()),
142 stderr: stderr_buf,
143 });
144 }
145 Err(error) => {
146 return Err(RecordError {
147 cause: format!("failed to inspect profile output: {error}"),
148 stderr: stderr_buf,
149 });
150 }
151 }
152
153 Ok(())
154}
155
156pub(super) fn parse(path: &Path) -> anyhow::Result<RawProfile> {
157 let profile = file::open(path).context("cannot open profile")?;
158 let mut decoder = GzDecoder::new(profile);
159 let mut json_str = String::new();
160
161 decoder
162 .read_to_string(&mut json_str)
163 .context("cannot decompress profile")?;
164
165 let data: serde_json::Value =
166 serde_json::from_str(&json_str).context("invalid profile JSON")?;
167
168 let threads = data
169 .get("threads")
170 .and_then(|t| t.as_array())
171 .ok_or_else(|| anyhow!("no threads in profile"))?;
172
173 let usable_threads = threads.iter().filter(|t| {
174 t.get("name")
175 .and_then(|n| n.as_str())
176 .is_none_or(|n| n != "samply")
177 });
178 let thread = usable_threads
179 .max_by_key(|t| {
180 t.get("samples")
181 .and_then(|s| s.get("stack"))
182 .and_then(|s| s.as_array())
183 .map_or(0, Vec::len)
184 })
185 .ok_or_else(|| anyhow!("no usable thread in profile"))?;
186
187 let strings: Vec<String> = thread
188 .get("stringArray")
189 .and_then(|a| a.as_array())
190 .ok_or_else(|| anyhow!("missing stringArray"))?
191 .iter()
192 .map(|v| v.as_str().unwrap_or("").to_string())
193 .collect();
194
195 let func_names: Vec<usize> = json_usize_array(
196 thread
197 .get("funcTable")
198 .and_then(|t| t.get("name"))
199 .ok_or_else(|| anyhow!("missing funcTable.name"))?,
200 );
201 let frame_funcs: Vec<usize> = json_usize_array(
202 thread
203 .get("frameTable")
204 .and_then(|t| t.get("func"))
205 .ok_or_else(|| anyhow!("missing frameTable.func"))?,
206 );
207 let stack_frames: Vec<usize> = json_usize_array(
208 thread
209 .get("stackTable")
210 .and_then(|t| t.get("frame"))
211 .ok_or_else(|| anyhow!("missing stackTable.frame"))?,
212 );
213 let stack_prefix = thread.get("stackTable").and_then(|t| t.get("prefix"));
214 let stack_prefix_values = stack_prefix
215 .and_then(|a| a.as_array())
216 .ok_or_else(|| anyhow!("missing stackTable.prefix"))?;
217 let stack_prefixes: Vec<Option<usize>> = stack_prefix_values
218 .iter()
219 .map(|v| v.as_u64().map(|n| n as usize))
220 .collect();
221
222 let sample_stack = thread.get("samples").and_then(|s| s.get("stack"));
223 let stack_values = sample_stack
224 .and_then(|a| a.as_array())
225 .ok_or_else(|| anyhow!("missing samples.stack"))?;
226 let stacks: Vec<Option<usize>> = stack_values
227 .iter()
228 .map(|v| v.as_u64().map(|n| n as usize))
229 .collect();
230
231 let weight_values = thread
232 .get("samples")
233 .and_then(|s| s.get("weight"))
234 .and_then(|a| a.as_array());
235 let weights: Vec<u64> = weight_values.map_or_else(
236 || vec![1; stacks.len()],
237 |arr| arr.iter().map(|v| v.as_u64().unwrap_or(1)).collect(),
238 );
239
240 let total_weight: u64 = weights.iter().sum();
241
242 let mut samples = Vec::with_capacity(stacks.len());
243 let mut seen = FastSet::default();
244
245 for (i, stack_idx) in stacks.iter().enumerate() {
246 let Some(mut curr) = *stack_idx else {
247 continue;
248 };
249 let weight = weights[i];
251 let mut frames = Vec::new();
252
253 seen.clear();
254
255 while seen.insert(curr) {
256 let Some(&frame_idx) = stack_frames.get(curr) else {
257 break;
258 };
259 let Some(&func_idx) = frame_funcs.get(frame_idx) else {
260 break;
261 };
262 let Some(&name_idx) = func_names.get(func_idx) else {
263 break;
264 };
265
266 frames.push(name_idx);
267
268 match stack_prefixes.get(curr) {
269 Some(Some(parent)) => curr = *parent,
270 _ => break,
271 }
272 }
273
274 samples.push(StackSample { frames, weight });
275 }
276
277 Ok(RawProfile {
278 samples,
279 strings,
280 total_weight,
281 })
282}
283
284pub(super) fn resolve_symbols(
285 strings: &[String],
286 binary: &Path,
287 quiet: bool,
288) -> (FastMap<String, String>, u32) {
289 let hex_addrs: Vec<&str> = strings
290 .iter()
291 .filter(|s| s.starts_with("0x"))
292 .map(String::as_str)
293 .collect();
294
295 if hex_addrs.is_empty() {
296 return (FastMap::default(), 0);
297 }
298
299 if !quiet {
300 output::detail(&format!("Resolving {} addresses...", hex_addrs.len()));
301 }
302
303 let mut cache = FastMap::default();
304
305 cache.reserve(hex_addrs.len());
306
307 if cfg!(target_os = "macos") {
308 cache = resolve_atos_batch(&hex_addrs, binary);
309 } else {
310 let batch = resolve_addr2line(&hex_addrs, binary);
311
312 cache.extend(batch);
313 }
314
315 let unresolved =
316 u32::try_from(cache.values().filter(|v| v.starts_with("0x")).count()).unwrap_or(u32::MAX);
317
318 (cache, unresolved)
319}
320
321fn macho_text_vmaddr(binary: &Path) -> Option<u64> {
323 let out = Command::new("otool")
324 .args(["-l"])
325 .arg(binary.as_os_str())
326 .stdout(Stdio::piped())
327 .stderr(Stdio::null())
328 .output()
329 .ok()?;
330
331 let stdout = String::from_utf8_lossy(&out.stdout);
332 let mut lines = stdout.lines().peekable();
333
334 while let Some(line) = lines.next() {
335 if line.trim().starts_with("segname __TEXT") {
336 if let Some(vmaddr_line) = lines.next() {
337 let trimmed = vmaddr_line.trim();
338
339 if let Some(hex) = trimmed.strip_prefix("vmaddr ") {
340 return u64::from_str_radix(hex.trim().trim_start_matches("0x"), HEX_RADIX)
341 .ok();
342 }
343 }
344
345 break;
346 }
347 }
348
349 None
350}
351
352fn resolve_atos_batch(addrs: &[&str], binary: &Path) -> FastMap<String, String> {
353 let mut result = FastMap::default();
354
355 result.reserve(addrs.len());
356 let vmaddr = macho_text_vmaddr(binary).unwrap_or(0);
357
358 let abs_addrs: Vec<String> = addrs
359 .iter()
360 .map(|a| {
361 let offset = u64::from_str_radix(a.trim_start_matches("0x"), HEX_RADIX).unwrap_or(0);
362
363 format!("0x{:x}", offset + vmaddr)
364 })
365 .collect();
366
367 let output = Command::new("atos")
368 .arg("-o")
369 .arg(binary.as_os_str())
370 .args(&abs_addrs)
371 .stdout(Stdio::piped())
372 .stderr(Stdio::null())
373 .output();
374
375 let Ok(output) = output else {
376 for a in addrs {
378 result.insert(a.to_string(), a.to_string());
379 }
380
381 return result;
382 };
383
384 let stdout = String::from_utf8_lossy(&output.stdout);
385 let lines: Vec<&str> = stdout.trim().lines().collect();
386
387 for (i, addr) in addrs.iter().enumerate() {
388 let sym = lines.get(i).unwrap_or(addr).trim();
389
390 if sym.is_empty() || sym.starts_with("0x") {
391 result.insert(addr.to_string(), addr.to_string());
392 continue;
393 }
394
395 if let Some(idx) = sym.find(" (in ") {
396 let func = &sym[..idx];
398 let rest = &sym[idx + ATOS_IN_PREFIX_LEN..];
400 let loc = rest.split(") ").last().unwrap_or("");
401
402 result.insert(addr.to_string(), format!("{func} {loc}").trim().to_string());
403 } else {
404 result.insert(addr.to_string(), sym.to_string());
405 }
406 }
407
408 result
409}
410
411fn resolve_addr2line(addrs: &[&str], binary: &Path) -> FastMap<String, String> {
412 let mut result = FastMap::default();
413
414 result.reserve(addrs.len());
415
416 let addr2line = which_tool(&["addr2line", "gaddr2line"]);
417 let cxxfilt = which_tool(&["c++filt", "gc++filt"]);
418
419 let Some(addr2line) = addr2line else {
420 for a in addrs {
422 result.insert(a.to_string(), a.to_string());
423 }
424
425 return result;
426 };
427
428 let output = Command::new(&addr2line)
429 .arg("-f")
430 .arg("-e")
431 .arg(binary.as_os_str())
432 .args(addrs)
433 .stdout(Stdio::piped())
434 .stderr(Stdio::null())
435 .output();
436
437 let Ok(output) = output else {
438 for a in addrs {
440 result.insert(a.to_string(), a.to_string());
441 }
442
443 return result;
444 };
445
446 let stdout = String::from_utf8_lossy(&output.stdout);
447 let lines: Vec<&str> = stdout.lines().collect();
448
449 let mut mangled: Vec<String> = Vec::with_capacity(addrs.len());
450
451 for (i, _) in addrs.iter().enumerate() {
452 let idx = i * ADDR2LINE_LINES_PER_ADDR;
453 let name = lines.get(idx).copied().unwrap_or("??");
454
455 mangled.push(name.to_string());
456 }
457
458 let demangled = if let Some(filt) = cxxfilt {
459 let input = mangled.join("\n");
460 let filt_out = Command::new(&filt)
461 .stdin(Stdio::piped())
462 .stdout(Stdio::piped())
463 .stderr(Stdio::null())
464 .spawn()
465 .and_then(|mut child| {
466 use std::io::Write;
467
468 if let Some(ref mut stdin) = child.stdin {
469 let _ = stdin.write_all(input.as_bytes());
470 }
471
472 child.wait_with_output()
473 });
474
475 match filt_out {
476 Ok(o) => String::from_utf8_lossy(&o.stdout)
477 .lines()
478 .map(String::from)
479 .collect(),
480 Err(_) => mangled.clone(),
481 }
482 } else {
483 mangled.clone()
484 };
485
486 for (i, addr) in addrs.iter().enumerate() {
487 let m = &mangled[i];
489
490 if m == "??" || m.is_empty() {
491 result.insert(addr.to_string(), addr.to_string());
492 } else {
493 let d = demangled.get(i).unwrap_or(m);
494
495 result.insert(addr.to_string(), d.trim().to_string());
496 }
497 }
498
499 result
500}
501
502fn which_tool(names: &[&str]) -> Option<PathBuf> {
503 for name in names {
505 if let Ok(output) = Command::new("which")
506 .arg(name)
507 .stdout(Stdio::piped())
508 .stderr(Stdio::null())
509 .output()
510 {
511 if output.status.success() {
512 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
513
514 if !path.is_empty() {
515 return Some(PathBuf::from(path));
516 }
517 }
518 }
519 }
520
521 None
522}
523
524fn json_usize_array(val: &serde_json::Value) -> Vec<usize> {
525 val.as_array()
526 .map(|a| a.iter().map(|v| v.as_u64().unwrap_or(0) as usize).collect())
527 .unwrap_or_default()
528}
529
530pub(super) fn check_samply() -> bool {
531 which_tool(&["samply"]).is_some()
532}