Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions profile-bee/bin/profile-bee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,16 @@ async fn main() -> std::result::Result<(), anyhow::Error> {
println!("Profiling PID {}..", target_pid);
}

// Warn if profiling an existing Node.js process without perf-map file.
// When we spawn the process ourselves (--cmd / -- <command>), NODE_OPTIONS
// is injected automatically by setup_process_to_profile(). This warning
// only applies to --pid targeting an already-running process.
if spawn.is_none() {
if let Some(target_pid) = pid {
warn_nodejs_without_perf_map(target_pid);
}
}

// Take ownership of ring buffer map after DWARF loading
let ring_buf = setup_ring_buffer(&mut ebpf_profiler.bpf)?;

Expand Down Expand Up @@ -781,6 +791,38 @@ async fn main() -> std::result::Result<(), anyhow::Error> {
Ok(())
}

/// Warn if profiling an already-running Node.js process that lacks a perf-map
/// file. Without `--perf-prof`, V8 JIT-compiled JavaScript functions show as
/// `[unknown]` in flamegraphs because there is no symbol information for
/// dynamically generated machine code in anonymous memory mappings.
fn warn_nodejs_without_perf_map(pid: u32) {
// Check if the target is a Node.js process by reading /proc/<pid>/exe
let exe_link = format!("/proc/{}/exe", pid);
let exe_path = match std::fs::read_link(&exe_link) {
Ok(p) => p,
Err(_) => return,
};
let basename = exe_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !matches!(basename, "node" | "nodejs" | "nsolid") {
return;
}

// Check for perf-map file
let perf_map = format!("/tmp/perf-{}.map", pid);
if std::path::Path::new(&perf_map).exists() {
return; // perf-map file present, JIT symbols will be resolved
}

eprintln!(
"\n\x1b[33mWarning: Profiling Node.js process (PID {}) without JIT symbol support.\x1b[0m",
pid
);
eprintln!("JavaScript function names will appear as [unknown] in the flamegraph.");
eprintln!("To enable JIT symbol resolution, restart Node.js with:");
eprintln!(" node --perf-basic-prof --interpreted-frames-native-stack <script>");
eprintln!("Or use profile-bee's auto-injection: probee -- node <script>\n");
}

/// Handle --list-probes: resolve and display matching symbols, then exit.
/// Accepts unified prefix syntax: 'uprobe:pthread_*', 'kprobe:vfs_*', or bare spec: 'pthread_*'
fn handle_list_probes(
Expand Down Expand Up @@ -1412,6 +1454,13 @@ async fn run_combined_mode(
let (mut ebpf_profiler, ring_buf, tgid_request_tx) =
setup_ebpf_and_dwarf(&mut config, &perf_tx, pid, opt.dwarf.unwrap_or(false))?;

// Warn if targeting an existing Node.js process without perf-map
if spawn.is_none() {
if let Some(target_pid) = pid {
warn_nodejs_without_perf_map(target_pid);
}
}

// TUI app + update handle
let update_mode = parse_update_mode(&opt.update_mode);
let mut app = if let Some(buf) = output_buffer {
Expand Down Expand Up @@ -1513,6 +1562,13 @@ async fn run_tui_mode(opt: Opt) -> std::result::Result<(), anyhow::Error> {
let (mut ebpf_profiler, ring_buf, tgid_request_tx) =
setup_ebpf_and_dwarf(&mut config, &perf_tx, pid, opt.dwarf.unwrap_or(false))?;

// Warn if targeting an existing Node.js process without perf-map
if spawn.is_none() {
if let Some(target_pid) = pid {
warn_nodejs_without_perf_map(target_pid);
}
}

// TUI app + update handle
let update_mode = parse_update_mode(&opt.update_mode);
let mut app = if let Some(buf) = output_buffer {
Expand Down
24 changes: 22 additions & 2 deletions profile-bee/src/dwarf_unwind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,14 +610,34 @@ impl DwarfUnwindManager {
continue;
}

let start_addr = map.address.0;
let end_addr = map.address.1;

// Anonymous executable mappings (JIT code from V8, JVM, etc.) have no
// backing ELF file and thus no DWARF unwind info. Register them as
// FP-only zones (shard_id = SHARD_NONE, table_count = 0) so the eBPF
// unwinder uses frame-pointer walking through these regions instead of
// failing the LPM lookup entirely. This is critical for mixed stacks
// (native → JIT → native) where the JIT frames need FP unwinding but
// surrounding native frames should still use DWARF.
if matches!(&map.pathname, MMapPath::Anonymous | MMapPath::Other(_)) {
mappings.push(ExecMapping {
begin: start_addr,
end: end_addr,
load_bias: 0,
shard_id: profile_bee_common::SHARD_NONE,
_pad1: [0; 2],
table_count: 0,
});
continue;
}

let file_path = match &map.pathname {
MMapPath::Path(p) => p.to_path_buf(),
MMapPath::Vdso => std::path::PathBuf::from("[vdso]"),
_ => continue,
};

let start_addr = map.address.0;
let end_addr = map.address.1;
let file_offset = map.offset;
let is_vdso = matches!(&map.pathname, MMapPath::Vdso);

Expand Down
100 changes: 87 additions & 13 deletions profile-bee/src/spawn.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::io::Error;
// use std::process::{Child, Command, Stdio};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::process::{Child, ChildStderr, ChildStdout, Command};
Expand Down Expand Up @@ -34,20 +34,29 @@ pub struct SpawnProcess {
}

impl SpawnProcess {
pub fn spawn(program: &str, args: &[&str]) -> Result<(Self, StopHandler), Error> {
Self::spawn_internal(program, args, false)
pub fn spawn(
program: &str,
args: &[&str],
extra_env: &[(&str, String)],
) -> Result<(Self, StopHandler), Error> {
Self::spawn_internal(program, args, false, extra_env)
}

/// Spawn the child with piped stdout and stderr so the parent can
/// capture its output (e.g. for displaying in the TUI).
pub fn spawn_captured(program: &str, args: &[&str]) -> Result<(Self, StopHandler), Error> {
Self::spawn_internal(program, args, true)
pub fn spawn_captured(
program: &str,
args: &[&str],
extra_env: &[(&str, String)],
) -> Result<(Self, StopHandler), Error> {
Self::spawn_internal(program, args, true, extra_env)
}

fn spawn_internal(
program: &str,
args: &[&str],
capture: bool,
extra_env: &[(&str, String)],
) -> Result<(Self, StopHandler), Error> {
use std::process::Stdio;

Expand All @@ -56,6 +65,9 @@ impl SpawnProcess {

let mut cmd = Command::new(program);
cmd.args(args);
for (key, value) in extra_env {
cmd.env(key, value);
}
if capture {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
}
Expand Down Expand Up @@ -159,6 +171,59 @@ impl Drop for SpawnProcess {
}
}

/// Check if a program name looks like Node.js.
fn is_nodejs_program(program: &str) -> bool {
let basename = Path::new(program)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(program);
matches!(basename, "node" | "nodejs" | "nsolid")
}

/// Build extra environment variables for runtime-specific profiling support.
///
/// For Node.js processes, injects `NODE_OPTIONS` with `--perf-prof` (writes
/// `/tmp/perf-<pid>.map` for JIT symbol resolution) and
/// `--interpreted-frames-native-stack` (enables frame pointers in interpreted
/// frames for reliable stack unwinding).
///
/// Merges with any existing `NODE_OPTIONS` from the parent environment.
fn build_runtime_env(program: &str) -> Vec<(&'static str, String)> {
Comment on lines +183 to +191

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix docstring: mentions --perf-prof but code uses --perf-basic-prof.

The inline comment on lines 195-196 correctly explains the flag difference, but the docstring is inconsistent.

📝 Suggested fix
 /// Build extra environment variables for runtime-specific profiling support.
 ///
-/// For Node.js processes, injects `NODE_OPTIONS` with `--perf-prof` (writes
+/// For Node.js processes, injects `NODE_OPTIONS` with `--perf-basic-prof` (writes
 /// `/tmp/perf-<pid>.map` for JIT symbol resolution) and
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@profile-bee/src/spawn.rs` around lines 183 - 191, The docstring for the
function build_runtime_env incorrectly states that we inject the Node flag
`--perf-prof` while the code actually uses `--perf-basic-prof`; update the doc
comment to mention `--perf-basic-prof` (and optionally note that this produces
/tmp/perf-<pid>.map for JIT symbol resolution) and ensure the description of
`--interpreted-frames-native-stack` remains accurate so the documentation
matches the implementation in build_runtime_env.

let mut env = Vec::new();

if is_nodejs_program(program) {
// --perf-basic-prof: writes /tmp/perf-<pid>.map with JIT symbol addresses
// (NOT --perf-prof which writes a binary jitdump for `perf inject`)
// --interpreted-frames-native-stack: use native frames for interpreted JS
// so the frame-pointer unwinder can walk through them
let node_flags = "--perf-basic-prof --interpreted-frames-native-stack";

// Merge with existing NODE_OPTIONS if set
let value = match std::env::var("NODE_OPTIONS") {
Ok(existing) if !existing.is_empty() => {
// Don't duplicate flags if they're already present
let mut combined = existing.clone();
if !existing.contains("--perf-basic-prof") {
combined.push_str(" --perf-basic-prof");
}
if !existing.contains("--interpreted-frames-native-stack") {
combined.push_str(" --interpreted-frames-native-stack");
}
combined
}
_ => node_flags.to_string(),
};

tracing::info!(
"Node.js detected: injecting NODE_OPTIONS=\"{}\" for JIT symbol resolution",
value
);
env.push(("NODE_OPTIONS", value));
}

env
}

/// Sets up the process to profile if a command is provided.
///
/// Returns `(Option<StopHandler>, Option<SpawnProcess>)`. When no command
Expand All @@ -167,25 +232,28 @@ impl Drop for SpawnProcess {
/// When `capture_output` is true, the child's stdout/stderr are piped so
/// the caller can read them (e.g. for TUI display). Use
/// [`SpawnProcess::take_stdout`] / [`take_stderr`] to obtain the handles.
///
/// For Node.js commands, automatically injects `NODE_OPTIONS` environment
/// variables to enable JIT symbol resolution via perf-map files.
pub fn setup_process_to_profile(
cmd: &Option<String>,
command: &[String],
capture_output: bool,
) -> anyhow::Result<(Option<StopHandler>, Option<SpawnProcess>)> {
let spawn_fn = if capture_output {
SpawnProcess::spawn_captured
} else {
SpawnProcess::spawn
};

// Prefer the new command format (--) over the old --cmd format
if !command.is_empty() {
let program = &command[0];
let args: Vec<&str> = command[1..].iter().map(|s| s.as_str()).collect();

tracing::info!("Running command: {} {}", program, args.join(" "));

let (child, stopper) = spawn_fn(program, &args)?;
let extra_env = build_runtime_env(program);
let spawn_fn = if capture_output {
SpawnProcess::spawn_captured
} else {
SpawnProcess::spawn
};
let (child, stopper) = spawn_fn(program, &args, &extra_env)?;
tracing::info!("Profiling PID {}..", child.pid());

return Ok((Some(stopper), Some(child)));
Expand All @@ -201,7 +269,13 @@ pub fn setup_process_to_profile(

// todo: use shelltools
let args: Vec<_> = cmd.split(' ').collect();
let (child, stopper) = spawn_fn(args[0], &args[1..])?;
let extra_env = build_runtime_env(args[0]);
let spawn_fn = if capture_output {
SpawnProcess::spawn_captured
} else {
SpawnProcess::spawn
};
let (child, stopper) = spawn_fn(args[0], &args[1..], &extra_env)?;

tracing::info!("Profiling PID {}..", child.pid());

Expand Down
Loading
Loading