diff --git a/src/executor/helpers/detect_executable.rs b/src/executor/helpers/detect_executable.rs new file mode 100644 index 00000000..c45aa87e --- /dev/null +++ b/src/executor/helpers/detect_executable.rs @@ -0,0 +1,54 @@ +use std::path::Path; + +/// Characters that act as command separators in shell commands. +const SHELL_SEPARATORS: &[char] = &['|', ';', '&', '(', ')']; + +/// Split a command string into tokens on whitespace and shell operators, +/// extracting the file name from each token (stripping directory paths). +fn tokenize(command: &str) -> impl Iterator + '_ { + command + .split(|c: char| c.is_whitespace() || SHELL_SEPARATORS.contains(&c)) + .filter(|t| !t.is_empty()) + .filter_map(|token| Path::new(token).file_name()?.to_str()) +} + +/// Check if a command string contains any of the given executable names. +/// +/// Splits the command into tokens on whitespace and shell operators, then checks +/// for exact matches on the file name component. This is strictly better than +/// `command.contains("java")` which would false-positive on "javascript". +pub fn command_has_executable(command: &str, names: &[&str]) -> bool { + tokenize(command).any(|token| names.contains(&token)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("java -jar bench.jar", &["java"])] + #[case("/usr/bin/java -jar bench.jar", &["java"])] + #[case("FOO=bar java -jar bench.jar", &["java"])] + #[case("cd /app && gradle bench", &["gradle"])] + #[case("cat file | python script.py", &["python"])] + #[case("sudo java -jar bench.jar", &["java"])] + #[case("(cd /app && java -jar bench.jar)", &["java"])] + #[case("setup.sh; java -jar bench.jar", &["java"])] + #[case("try_first || java -jar bench.jar", &["java"])] + #[case("cargo codspeed bench\npytest tests/ --codspeed", &["cargo"])] + #[case("mvn test", &["gradle", "java", "maven", "mvn"])] + #[case("./java -jar bench.jar", &["java"])] + fn matches(#[case] command: &str, #[case] names: &[&str]) { + assert!(command_has_executable(command, names)); + } + + #[rstest] + #[case("javascript-runtime run", &["java"])] + #[case("/home/user/javascript/run.sh", &["java"])] + #[case("scargoship build", &["cargo"])] + #[case("node index.js", &["gradle", "java", "maven", "mvn"])] + fn does_not_match(#[case] command: &str, #[case] names: &[&str]) { + assert!(!command_has_executable(command, names)); + } +} diff --git a/src/executor/helpers/env.rs b/src/executor/helpers/env.rs index 07283b43..c8bfcd07 100644 --- a/src/executor/helpers/env.rs +++ b/src/executor/helpers/env.rs @@ -41,6 +41,18 @@ pub fn get_base_injected_env( ), ]); + // Java: Enable frame pointers and perf map generation for flamegraph profiling. + // - UnlockDiagnosticVMOptions must come before DumpPerfMapAtExit (diagnostic option). + // - PreserveFramePointer: Preserves frame pointers for profiling. + // - DumpPerfMapAtExit: Writes /tmp/perf-.map on JVM exit for symbol resolution. + // - DebugNonSafepoints: Enables debug info for JIT-compiled non-safepoint code. + if mode == RunnerMode::Walltime { + env.insert( + "JAVA_TOOL_OPTIONS", + "-XX:+PreserveFramePointer -XX:+UnlockDiagnosticVMOptions -XX:+DumpPerfMapAtExit -XX:+DebugNonSafepoints".into(), + ); + } + if let Some(version) = &config.go_runner_version { env.insert("CODSPEED_GO_RUNNER_VERSION", version.to_string()); } diff --git a/src/executor/helpers/mod.rs b/src/executor/helpers/mod.rs index 6303ae0a..3e433c7c 100644 --- a/src/executor/helpers/mod.rs +++ b/src/executor/helpers/mod.rs @@ -1,5 +1,6 @@ pub mod apt; pub mod command; +pub mod detect_executable; pub mod env; pub mod get_bench_command; pub mod harvest_perf_maps_for_pids; diff --git a/src/executor/helpers/run_with_env.rs b/src/executor/helpers/run_with_env.rs index a1b1aa61..5bfd7f5a 100644 --- a/src/executor/helpers/run_with_env.rs +++ b/src/executor/helpers/run_with_env.rs @@ -54,7 +54,7 @@ fn create_env_file(extra_env: &HashMap<&'static str, String>) -> Result>() .join("\n"); diff --git a/src/executor/wall_time/perf/mod.rs b/src/executor/wall_time/perf/mod.rs index bfbfc9aa..2816787b 100644 --- a/src/executor/wall_time/perf/mod.rs +++ b/src/executor/wall_time/perf/mod.rs @@ -3,6 +3,7 @@ use crate::cli::UnwindingMode; use crate::executor::ExecutorConfig; use crate::executor::helpers::command::CommandBuilder; +use crate::executor::helpers::detect_executable::command_has_executable; use crate::executor::helpers::env::is_codspeed_debug_enabled; use crate::executor::helpers::harvest_perf_maps_for_pids::harvest_perf_maps_for_pids; use crate::executor::helpers::run_command_with_log_pipe::run_command_with_log_pipe_and_callback; @@ -96,12 +97,15 @@ impl PerfRunner { // Infer the unwinding mode from the benchmark cmd let (cg_mode, stack_size) = if let Some(mode) = config.perf_unwinding_mode { (mode, None) - } else if config.command.contains("cargo") { + } else if command_has_executable( + &config.command, + &["gradle", "gradlew", "java", "maven", "mvn", "mvnw"], + ) { + // In Java, we must use FP unwinding otherwise we'll have broken call stacks. + (UnwindingMode::FramePointer, None) + } else if command_has_executable(&config.command, &["cargo"]) { (UnwindingMode::Dwarf, None) - } else if config.command.contains("pytest") - || config.command.contains("uv") - || config.command.contains("python") - { + } else if command_has_executable(&config.command, &["pytest", "uv", "python", "python3"]) { // Note that the higher the stack size, the larger the file, although it is mitigated // by zstd compression (UnwindingMode::Dwarf, Some(16 * 1024))