Skip to main content

forge/profile/
samply.rs

1// #t(file: rust_alloc_in_loop) CLI symbol resolution caches build owned strings; cold path.
2// #t(file: rust_println) CLI binary, terminal output is the purpose.
3
4#![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
19/// addr2line outputs two lines per address (function name + location).
20const 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        // BOUNDS: `weights` always has the same length as `stacks` (parsed from same array or vec![1; stacks.len()])
250        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
321// Samply stores addresses as file offsets, but atos expects virtual addresses; add this vmaddr to convert.
322fn 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        // #t(block: rust_alloc_in_loop) building owned strings for fallback cache
377        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            // BOUNDS: idx from find() is a valid byte offset within sym
397            let func = &sym[..idx];
398            // BOUNDS: idx from find() + ATOS_IN_PREFIX_LEN (len of " (in ") is within bounds
399            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        // #t(block: rust_alloc_in_loop) building owned strings for symbol resolution cache
421        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        // #t(block: rust_alloc_in_loop) building owned strings for symbol resolution cache
439        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        // BOUNDS: `mangled` has exactly `addrs.len()` elements, built in the loop above
488        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    // #t(block: rust_alloc_in_loop) building owned path string from command output
504    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}