diff --git a/Makefile b/Makefile index 3b03c1af..51ce2e10 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,7 @@ endif test: @echo "Running unit tests..." go test -v ./... + cd tools/stackcollapse-perf && go test -v ./... .PHONY: update-deps update-deps: @@ -103,6 +104,25 @@ check_lint: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest golangci-lint run +.PHONY: check_vuln +check_vuln: + @echo "Running govulncheck to check for vulnerabilities..." + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + +.PHONY: check_sec +check_sec: + @echo "Running gosec to check for security issues..." + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec ./... + +.PHONY: check_semgrep +check_semgrep: + @echo "Running semgrep to check for security issues..." + @echo "Please install semgrep from https://semgrep.dev/docs/getting-started/installation/ if not already installed." + @echo "Running semgrep..." + semgrep scan + .PHONY: check_modernize check_modernize: @echo "Running go-modernize to check for modernization opportunities..." @@ -114,7 +134,7 @@ modernize: go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix -test ./... .PHONY: check -check: check_format check_vet check_static check_license check_lint check_modernize +check: check_format check_vet check_static check_license check_lint check_vuln check_modernize .PHONY: sweep sweep: diff --git a/THIRD_PARTY_PROGRAMS b/THIRD_PARTY_PROGRAMS index a0bf8b81..5138ef76 100644 --- a/THIRD_PARTY_PROGRAMS +++ b/THIRD_PARTY_PROGRAMS @@ -567,29 +567,6 @@ NO WARRANTY END OF TERMS AND CONDITIONS ------------------------------------------------------------- -stackcollapse-perf.pl -# Copyright 2012 Joyent, Inc. All rights reserved. -# Copyright 2012 Brendan Gregg. All rights reserved. -# -# CDDL HEADER START -# -# The contents of this file are subject to the terms of the -# Common Development and Distribution License (the "License"). -# You may not use this file except in compliance with the License. -# -# You can obtain a copy of the license at docs/cddl1.txt or -# http://opensource.org/licenses/CDDL-1.0. -# See the License for the specific language governing permissions -# and limitations under the License. -# -# When distributing Covered Code, include this CDDL HEADER in each -# file and include the License file at docs/cddl1.txt. -# If applicable, add the following below this CDDL HEADER, with the -# fields enclosed by brackets "[]" replaced with your own identifying -# information: Portions Copyright [yyyy] [name of copyright owner] -# -# CDDL HEADER END -------------------------------------------------------------- stress-ng Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/cmd/flame/flame.go b/cmd/flame/flame.go index 3695c1da..75f45635 100644 --- a/cmd/flame/flame.go +++ b/cmd/flame/flame.go @@ -9,6 +9,7 @@ import ( "os" "perfspect/internal/common" "perfspect/internal/report" + "perfspect/internal/util" "slices" "strconv" "strings" @@ -40,15 +41,17 @@ var Cmd = &cobra.Command{ var ( flagDuration int flagFrequency int - flagPid int + flagPids []int flagNoSystemSummary bool + flagMaxDepth int ) const ( flagDurationName = "duration" flagFrequencyName = "frequency" - flagPidName = "pid" + flagPidsName = "pids" flagNoSystemSummaryName = "no-summary" + flagMaxDepthName = "max-depth" ) func init() { @@ -56,8 +59,9 @@ func init() { Cmd.Flags().StringSliceVar(&common.FlagFormat, common.FlagFormatName, []string{report.FormatAll}, "") Cmd.Flags().IntVar(&flagDuration, flagDurationName, 30, "") Cmd.Flags().IntVar(&flagFrequency, flagFrequencyName, 11, "") - Cmd.Flags().IntVar(&flagPid, flagPidName, 0, "") + Cmd.Flags().IntSliceVar(&flagPids, flagPidsName, nil, "") Cmd.Flags().BoolVar(&flagNoSystemSummary, flagNoSystemSummaryName, false, "") + Cmd.Flags().IntVar(&flagMaxDepth, flagMaxDepthName, 0, "") common.AddTargetFlags(Cmd) @@ -101,13 +105,17 @@ func getFlagGroups() []common.FlagGroup { Help: "number of samples taken per second", }, { - Name: flagPidName, - Help: "pid to collect data from. If not specified, all pids will be collected", + Name: flagPidsName, + Help: "comma separated list of PIDs. If not specified, all PIDs will be collected", }, { Name: common.FlagFormatName, Help: fmt.Sprintf("choose output format(s) from: %s", strings.Join(append([]string{report.FormatAll}, report.FormatHtml, report.FormatTxt, report.FormatJson), ", ")), }, + { + Name: flagMaxDepthName, + Help: "maximum render depth of call stack in flamegraph (0 = no limit)", + }, { Name: flagNoSystemSummaryName, Help: "do not include system summary table in report", @@ -159,8 +167,15 @@ func validateFlags(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return err } - if flagPid < 0 { - err := fmt.Errorf("pid must be greater than or equal to 0") + for _, pid := range flagPids { + if pid < 0 { + err := fmt.Errorf("PID must be greater than or equal to 0") + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return err + } + } + if flagMaxDepth < 0 { + err := fmt.Errorf("max depth must be greater than or equal to 0") fmt.Fprintf(os.Stderr, "Error: %v\n", err) return err } @@ -177,14 +192,15 @@ func runCmd(cmd *cobra.Command, args []string) error { if !flagNoSystemSummary { tableNames = append(tableNames, report.BriefSysSummaryTableName) } - tableNames = append(tableNames, report.CodePathFrequencyTableName) + tableNames = append(tableNames, report.CallStackFrequencyTableName) reportingCommand := common.ReportingCommand{ Cmd: cmd, ReportNamePost: "flame", ScriptParams: map[string]string{ "Frequency": strconv.Itoa(flagFrequency), "Duration": strconv.Itoa(flagDuration), - "PID": strconv.Itoa(flagPid), + "PIDs": strings.Join(util.IntSliceToStringSlice(flagPids), ","), + "MaxDepth": strconv.Itoa(flagMaxDepth), }, TableNames: tableNames, } diff --git a/internal/report/render_html.go b/internal/report/render_html.go index 715ac60d..c7778601 100644 --- a/internal/report/render_html.go +++ b/internal/report/render_html.go @@ -1284,7 +1284,7 @@ func gaudiTelemetryTableHTMLRenderer(tableValues TableValues, targetName string) return out } -func codePathFrequencyTableHTMLRenderer(tableValues TableValues, targetName string) string { +func callStackFrequencyTableHTMLRenderer(tableValues TableValues, targetName string) string { out := ` ` - out += renderFlameGraph("System", tableValues, "System Paths") - out += renderFlameGraph("Java", tableValues, "Java Paths") + out += renderFlameGraph("Native", tableValues, "Native Stacks") + out += renderFlameGraph("Java", tableValues, "Java Stacks") return out } diff --git a/internal/report/render_html_flamegraph.go b/internal/report/render_html_flamegraph.go index 02d3b8bf..5e679c89 100644 --- a/internal/report/render_html_flamegraph.go +++ b/internal/report/render_html_flamegraph.go @@ -8,8 +8,9 @@ import ( "bytes" "encoding/json" "fmt" - "log" + "log/slog" "math/rand/v2" // nosemgrep + "slices" "strconv" "strings" texttemplate "text/template" // nosemgrep @@ -81,11 +82,6 @@ type flameGraphTemplateStruct struct { // Folded data conversion adapted from https://github.com/spiermar/burn // Copyright © 2017 Martin Spier // Apache License, Version 2.0 -func reverse(strings []string) { - for i, j := 0, len(strings)-1; i < j; i, j = i+1, j-1 { - strings[i], strings[j] = strings[j], strings[i] - } -} type Node struct { Name string @@ -123,24 +119,24 @@ func (n *Node) MarshalJSON() ([]byte, error) { }) } -var maxStackDepth = 50 - -func convertFoldedToJSON(folded string) (out string, err error) { +func convertFoldedToJSON(folded string, maxStackDepth int) (out string, err error) { rootNode := Node{Name: "root", Value: 0, Children: make(map[string]*Node)} scanner := bufio.NewScanner(strings.NewReader(folded)) for scanner.Scan() { line := scanner.Text() - sep := strings.LastIndex(line, " ") - s := line[:sep] - v := line[sep+1:] - stack := strings.Split(s, ";") - reverse(stack) - if len(stack) > maxStackDepth { - log.Printf("Trimming call stack depth from %d to %d", len(stack), maxStackDepth) - stack = stack[:maxStackDepth] + sep := strings.LastIndex(line, " ") // space separates the call stack from the count + callstack := line[:sep] + count := line[sep+1:] + stack := strings.Split(callstack, ";") // semicolon separates the functions in the call stack + slices.Reverse(stack) + if maxStackDepth > 0 { + if len(stack) > maxStackDepth { + slog.Info("Trimming call stack depth", slog.Int("stack length", len(stack)), slog.Int("max depth", maxStackDepth)) + stack = stack[:maxStackDepth] + } } var i int - i, err = strconv.Atoi(v) + i, err = strconv.Atoi(count) if err != nil { return } @@ -152,9 +148,21 @@ func convertFoldedToJSON(folded string) (out string, err error) { } func renderFlameGraph(header string, tableValues TableValues, field string) (out string) { + maxDepthFieldIndex, err := getFieldIndex("Maximum Render Depth", tableValues) + if err != nil { + slog.Error("didn't find expected field (Maximum Render Depth) in table", slog.String("error", err.Error())) + return + } + maxDepth := tableValues.Fields[maxDepthFieldIndex].Values[0] + maxStackDepth, err := strconv.Atoi(strings.TrimSpace(maxDepth)) + if err != nil { + slog.Error("failed to convert maximum stack depth", slog.String("error", err.Error())) + return + } fieldIdx, err := getFieldIndex(field, tableValues) if err != nil { - log.Panicf("didn't find expected field (%s) in table: %v", field, err) + slog.Error("didn't find expected field in table", slog.String("field", field), slog.String("error", err.Error())) + return } folded := tableValues.Fields[fieldIdx].Values[0] if folded == "" { @@ -166,10 +174,10 @@ func renderFlameGraph(header string, tableValues TableValues, field string) (out out += msg return } - jsonStacks, err := convertFoldedToJSON(folded) + jsonStacks, err := convertFoldedToJSON(folded, maxStackDepth) if err != nil { - log.Printf("failed to convert folded data: %v", err) - out += "Error." + slog.Error("failed to convert folded data", slog.String("error", err.Error())) + out = "" return } fg := texttemplate.Must(texttemplate.New("flameGraphTemplate").Parse(flameGraphTemplate)) @@ -180,8 +188,8 @@ func renderFlameGraph(header string, tableValues TableValues, field string) (out Header: header, }) if err != nil { - log.Printf("failed to render flame graph template: %v", err) - out += "Error." + slog.Error("failed to render flame graph template", slog.String("error", err.Error())) + out = "" return } out += buf.String() diff --git a/internal/report/table_defs.go b/internal/report/table_defs.go index 69fb5dec..6d6e1a40 100644 --- a/internal/report/table_defs.go +++ b/internal/report/table_defs.go @@ -129,7 +129,7 @@ const ( // config table names ConfigurationTableName = "Configuration" // flamegraph table names - CodePathFrequencyTableName = "Code Path Frequency" + CallStackFrequencyTableName = "Call Stack Frequency" // lock table names KernelLockAnalysisTableName = "Kernel Lock Analysis" // common table names @@ -742,15 +742,14 @@ var tableDefinitions = map[string]TableDefinition{ // // flamegraph tables // - CodePathFrequencyTableName: { - Name: CodePathFrequencyTableName, - MenuLabel: CodePathFrequencyTableName, + CallStackFrequencyTableName: { + Name: CallStackFrequencyTableName, + MenuLabel: CallStackFrequencyTableName, ScriptNames: []string{ - script.ProfileJavaScriptName, - script.ProfileSystemScriptName, + script.CollapsedCallStacksScriptName, }, - FieldsFunc: codePathFrequencyTableValues, - HTMLTableRendererFunc: codePathFrequencyTableHTMLRenderer}, + FieldsFunc: callStackFrequencyTableValues, + HTMLTableRendererFunc: callStackFrequencyTableHTMLRenderer}, // // kernel lock analysis tables // @@ -2382,10 +2381,11 @@ func gaudiTelemetryTableValues(outputs map[string]script.ScriptOutput) []Field { return fields } -func codePathFrequencyTableValues(outputs map[string]script.ScriptOutput) []Field { +func callStackFrequencyTableValues(outputs map[string]script.ScriptOutput) []Field { fields := []Field{ - {Name: "System Paths", Values: []string{systemFoldedFromOutput(outputs)}}, - {Name: "Java Paths", Values: []string{javaFoldedFromOutput(outputs)}}, + {Name: "Native Stacks", Values: []string{nativeFoldedFromOutput(outputs)}}, + {Name: "Java Stacks", Values: []string{javaFoldedFromOutput(outputs)}}, + {Name: "Maximum Render Depth", Values: []string{maxRenderDepthFromOutput(outputs)}}, } return fields } diff --git a/internal/report/table_helpers.go b/internal/report/table_helpers.go index 3f75b111..85554ac8 100644 --- a/internal/report/table_helpers.go +++ b/internal/report/table_helpers.go @@ -2080,29 +2080,28 @@ func sectionValueFromOutput(output string, sectionName string) string { } func javaFoldedFromOutput(outputs map[string]script.ScriptOutput) string { - sections := getSectionsFromOutput(outputs[script.ProfileJavaScriptName].Stdout) + sections := getSectionsFromOutput(outputs[script.CollapsedCallStacksScriptName].Stdout) if len(sections) == 0 { - slog.Warn("no sections in java profiling output") + slog.Warn("no sections in collapsed call stack output") return "" } javaFolded := make(map[string]string) re := regexp.MustCompile(`^async-profiler (\d+) (.*)$`) for header, stacks := range sections { - if stacks == "" { - slog.Info("no stacks for java process", slog.String("header", header)) - continue - } - if strings.HasPrefix(stacks, "Failed to inject profiler") { - slog.Warn("profiling data error", slog.String("header", header)) - continue - } match := re.FindStringSubmatch(header) if match == nil { - slog.Warn("profiling data error, regex didn't match header", slog.String("header", header)) continue } pid := match[1] processName := match[2] + if stacks == "" { + slog.Warn("no stacks for java process", slog.String("header", header)) + continue + } + if strings.HasPrefix(stacks, "Failed to inject profiler") { + slog.Error("profiling data error", slog.String("header", header)) + continue + } _, ok := javaFolded[processName] if processName == "" { processName = "java (" + pid + ")" @@ -2113,15 +2112,15 @@ func javaFoldedFromOutput(outputs map[string]script.ScriptOutput) string { } folded, err := mergeJavaFolded(javaFolded) if err != nil { - slog.Warn("err merging java stacks", slog.String("error", err.Error())) + slog.Error("failed to merge java stacks", slog.String("error", err.Error())) } return folded } -func systemFoldedFromOutput(outputs map[string]script.ScriptOutput) string { - sections := getSectionsFromOutput(outputs[script.ProfileSystemScriptName].Stdout) +func nativeFoldedFromOutput(outputs map[string]script.ScriptOutput) string { + sections := getSectionsFromOutput(outputs[script.CollapsedCallStacksScriptName].Stdout) if len(sections) == 0 { - slog.Warn("no sections in system profiling output") + slog.Warn("no sections in collapsed call stack output") return "" } var dwarfFolded, fpFolded string @@ -2137,7 +2136,21 @@ func systemFoldedFromOutput(outputs map[string]script.ScriptOutput) string { } folded, err := mergeSystemFolded(fpFolded, dwarfFolded) if err != nil { - slog.Warn("error merging folded stacks", slog.String("error", err.Error())) + slog.Error("failed to merge native stacks", slog.String("error", err.Error())) } return folded } + +func maxRenderDepthFromOutput(outputs map[string]script.ScriptOutput) string { + sections := getSectionsFromOutput(outputs[script.CollapsedCallStacksScriptName].Stdout) + if len(sections) == 0 { + slog.Warn("no sections in collapsed call stack output") + return "" + } + for header, content := range sections { + if header == "maximum depth" { + return content + } + } + return "" +} diff --git a/internal/report/table_helpers_dimm.go b/internal/report/table_helpers_dimm.go index 3e0191c1..e0ad06c5 100644 --- a/internal/report/table_helpers_dimm.go +++ b/internal/report/table_helpers_dimm.go @@ -5,7 +5,6 @@ package report import ( "fmt" - "log" "log/slog" "perfspect/internal/script" "regexp" @@ -67,7 +66,7 @@ func installedMemoryFromOutput(outputs map[string]script.ScriptOutput) string { if match != nil { size, err := strconv.Atoi(match[1]) if err != nil { - log.Printf("Don't recognize DIMM size format: %s", fields[1]) + slog.Error("Don't recognize DIMM size format.", slog.String("field", fields[1])) return "" } sum := count * size diff --git a/internal/script/script.go b/internal/script/script.go index 49d63260..c992611b 100644 --- a/internal/script/script.go +++ b/internal/script/script.go @@ -468,11 +468,13 @@ func prepareTargetToRunScripts(myTarget target.Target, scripts []ScriptDefinitio for _, dependency := range script.Depends { dependenciesToCopy[path.Join(targetArchitecture, dependency)] = 1 } - // add user's path to script - scriptWithPath := fmt.Sprintf("export PATH=\"%s\"\n%s", userPath, script.ScriptTemplate) + // add cd command to the script to change to the target's local temp directory + targetScript := fmt.Sprintf("cd %s\n%s", targetTempDirectory, script.ScriptTemplate) + // add PATH (including the target temporary directory) to the script + targetScript = fmt.Sprintf("export PATH=\"%s\"\n%s", userPath, targetScript) // write script to the target's local temp directory scriptPath := path.Join(localTempDir, myTarget.GetName(), scriptNameToFilename(script.Name)) - err = os.WriteFile(scriptPath, []byte(scriptWithPath), 0644) + err = os.WriteFile(scriptPath, []byte(targetScript), 0644) if err != nil { err = fmt.Errorf("error writing script to local file: %v", err) return diff --git a/internal/script/script_defs.go b/internal/script/script_defs.go index 89c6e111..568dff0c 100644 --- a/internal/script/script_defs.go +++ b/internal/script/script_defs.go @@ -112,10 +112,8 @@ const ( TurbostatTelemetryScriptName = "turbostat telemetry" InstructionTelemetryScriptName = "instruction telemetry" GaudiTelemetryScriptName = "gaudi telemetry" - // flamegraph scripts - ProfileJavaScriptName = "profile java" - ProfileSystemScriptName = "profile system" + CollapsedCallStacksScriptName = "collapsed call stacks" // lock scripts ProfileKernelLockScriptName = "profile kernel lock" ) @@ -1237,180 +1235,155 @@ fi Superuser: true, NeedsKill: true, }, - // profile (flamegraph) scripts - ProfileJavaScriptName: { - Name: ProfileJavaScriptName, - ScriptTemplate: `# JAVA app (async profiler) call stack collection -pid={{.PID}} + // flamegraph scripts + CollapsedCallStacksScriptName: { + Name: CollapsedCallStacksScriptName, + ScriptTemplate: `# Combined (perf record and async profiler) call stack collection +pids={{.PIDs}} duration={{.Duration}} frequency={{.Frequency}} +maxdepth={{.MaxDepth}} ap_interval=0 if [ "$frequency" -ne 0 ]; then - ap_interval=$((1000000000 / frequency)) + ap_interval=$((1000000000 / frequency)) fi -# if pid is provided, use it -if [ "$pid" -ne 0 ]; then - # check if the provided pid is running - if [ ! -d "/proc/$pid" ]; then - echo "pid $pid not running" - exit 1 - fi - # check if pid is a java process, i.e., command line contains java - if ! tr '\000' ' ' < /proc/"$pid"/cmdline | grep -q java; then - echo "pid $pid is not a java process" - exit 1 - fi - pids="$pid" -else - # get all java pids - pids=$( pgrep java ) -fi - -# check if any java pids are found -if [ -z "$pids" ]; then - echo "no java processes found" - exit 1 -fi - -# start java profiling for each java pid -declare -a java_pids=() -declare -a java_cmds=() -for pid in $pids ; do - java_pids+=("$pid") - java_cmds+=("$( tr '\000' ' ' < /proc/"$pid"/cmdline )") - # profile pid in background - async-profiler/profiler.sh start -i "$ap_interval" -o collapsed "$pid" -done - -# wait for the specified duration -sleep "$duration" - -# stop java profiling for each java pid -for idx in "${!java_pids[@]}"; do - pid="${java_pids[$idx]}" - cmd="${java_cmds[$idx]}" - echo "########## async-profiler $pid $cmd ##########" - async-profiler/profiler.sh stop -o collapsed "$pid" -done - -`, - Superuser: true, - Depends: []string{"async-profiler"}, - }, - ProfileSystemScriptName: { - Name: ProfileSystemScriptName, - ScriptTemplate: `# native (perf record) call stack collection -pid={{.PID}} -duration={{.Duration}} -frequency={{.Frequency}} - # Function to restore original settings and clean up -# This function will be called on exit restore_settings() { - echo "$PERF_EVENT_PARANOID" > /proc/sys/kernel/perf_event_paranoid - echo "$KPTR_RESTRICT" > /proc/sys/kernel/kptr_restrict - rm -f "$perf_fp_data" - rm -f "$perf_dwarf_data" - rm -f "$perf_dwarf_folded" - rm -f "$perf_fp_folded" + echo "$PERF_EVENT_PARANOID" > /proc/sys/kernel/perf_event_paranoid + echo "$KPTR_RESTRICT" > /proc/sys/kernel/kptr_restrict if [ -n "$perf_fp_pid" ]; then kill -0 $perf_fp_pid 2>/dev/null && kill -INT $perf_fp_pid fi if [ -n "$perf_dwarf_pid" ]; then kill -0 $perf_dwarf_pid 2>/dev/null && kill -INT $perf_dwarf_pid fi + for pid in "${java_pids[@]}"; do + async-profiler/profiler.sh stop -o collapsed "$pid" + done } -# create temporary output files -perf_fp_data=$(mktemp) -perf_dwarf_data=$(mktemp) -perf_dwarf_folded=$(mktemp) -perf_fp_folded=$(mktemp) - -# adjust perf_event_paranoid and kptr_restrict -PERF_EVENT_PARANOID=$( cat /proc/sys/kernel/perf_event_paranoid ) +# Adjust perf_event_paranoid and kptr_restrict +PERF_EVENT_PARANOID=$(cat /proc/sys/kernel/perf_event_paranoid) echo -1 >/proc/sys/kernel/perf_event_paranoid -KPTR_RESTRICT=$( cat /proc/sys/kernel/kptr_restrict ) +KPTR_RESTRICT=$(cat /proc/sys/kernel/kptr_restrict) echo 0 >/proc/sys/kernel/kptr_restrict # Ensure settings are restored on exit trap restore_settings EXIT -# if pid is not zero, check if the process is running -if [ "$pid" -ne 0 ]; then - if ! ps -p "$pid" > /dev/null; then - echo "Error: Process $pid is not running." - exit 1 - fi +# Check if at least one process is running +if [ -n "$pids" ]; then + IFS=',' read -r -a pid_array <<< "$pids" + for p in "${pid_array[@]}"; do + if ps -p "$p" > /dev/null; then + if tr '\000' ' ' < /proc/"$p"/cmdline | grep -q java; then + java_pids+=("$p") + fi + else + echo "Error: Process $p is not running." >&2 + exit 1 + fi + done +else + mapfile -t java_pids < <(pgrep java) fi -# frame pointer mode -# if pid was provided, use it -if [ "$pid" -ne 0 ]; then - perf record -F "$frequency" -p "$pid" -g -o "$perf_fp_data" -m 129 & +# Frame pointer mode +if [ -n "$pids" ]; then + perf record -F "$frequency" -p "$pids" -g -o perf_fp_data -m 129 & else - # if no pid was provided, use system-wide profiling - perf record -F "$frequency" -a -g -o "$perf_fp_data" -m 129 & + perf record -F "$frequency" -a -g -o perf_fp_data -m 129 & fi perf_fp_pid=$! if ! kill -0 $perf_fp_pid 2>/dev/null; then - echo "Failed to start perf record in frame pointer mode" - exit 1 + echo "Failed to start perf record in frame pointer mode" >&2 + exit 1 fi -# dwarf mode -# if pid was provided, use it -if [ "$pid" -ne 0 ]; then - perf record -F "$frequency" -p "$pid" -g -o "$perf_dwarf_data" -m 257 --call-graph dwarf,8192 & +# Dwarf mode +if [ -n "$pids" ]; then + perf record -F "$frequency" -p "$pids" -g -o perf_dwarf_data -m 257 --call-graph dwarf,8192 & else - # if no pid was provided, use system-wide profiling - perf record -F "$frequency" -a -g -o "$perf_dwarf_data" -m 257 --call-graph dwarf,8192 & + perf record -F "$frequency" -a -g -o perf_dwarf_data -m 257 --call-graph dwarf,8192 & fi perf_dwarf_pid=$! if ! kill -0 $perf_dwarf_pid 2>/dev/null; then - echo "Failed to start perf record in dwarf mode" - exit 1 + echo "Failed to start perf record in dwarf mode" >&2 + exit 1 fi -# wait for the specified duration +# Start Java profiling for each Java PID +for pid in "${java_pids[@]}"; do + java_cmds+=("$(tr '\000' ' ' < /proc/"$pid"/cmdline)") + async-profiler/profiler.sh start -i "$ap_interval" -o collapsed "$pid" +done + +# Wait for the specified duration sleep "$duration" -# stop perf recording +# Stop perf recording if ! kill -0 $perf_fp_pid 2>/dev/null; then - echo "Frame pointer mode already stopped" + echo "Frame pointer mode already stopped" >&2 else kill -INT $perf_fp_pid fi if ! kill -0 $perf_dwarf_pid 2>/dev/null; then - echo "Dwarf mode already stopped" + echo "Dwarf mode already stopped" >&2 else kill -INT $perf_dwarf_pid fi -# wait for perf to finish +# Stop Java profiling, write output to ap_folded_ files +for pid in "${java_pids[@]}"; do + async-profiler/profiler.sh stop -o collapsed -f ap_folded_"$pid" "$pid" +done + +# Wait for perf to finish wait ${perf_fp_pid} ${perf_dwarf_pid} -# collapse perf data -perf script -i "$perf_dwarf_data" | stackcollapse-perf.pl > "$perf_dwarf_folded" -perf script -i "$perf_fp_data" | stackcollapse-perf.pl > "$perf_fp_folded" +# Collapse perf data +if [ -f perf_dwarf_data ]; then + perf script -i perf_dwarf_data > perf_dwarf_stacks + stackcollapse-perf perf_dwarf_stacks > perf_dwarf_folded +else + echo "Error: perf_dwarf_data file not found" >&2 +fi +if [ -f perf_fp_data ]; then + perf script -i perf_fp_data > perf_fp_stacks + stackcollapse-perf perf_fp_stacks > perf_fp_folded +else + echo "Error: perf_fp_data file not found" >&2 +fi + +# Dump results to stdout +echo "########## maximum depth ##########" +echo "$maxdepth" -# Display results -if [ -f "$perf_dwarf_folded" ]; then - echo "########## perf_dwarf ##########" - cat "$perf_dwarf_folded" +if [ -f perf_dwarf_folded ]; then + echo "########## perf_dwarf ##########" + cat perf_dwarf_folded fi -if [ -f "$perf_fp_folded" ]; then - echo "########## perf_fp ##########" - cat "$perf_fp_folded" +if [ -f perf_fp_folded ]; then + echo "########## perf_fp ##########" + cat perf_fp_folded fi -# Clean up temporary files -rm -f "$perf_fp_data" "$perf_dwarf_data" "$perf_dwarf_folded" "$perf_fp_folded" +for idx in "${!java_pids[@]}"; do + pid="${java_pids[$idx]}" + cmd="${java_cmds[$idx]}" + echo "########## async-profiler $pid $cmd ##########" + if [ -f ap_folded_"$pid" ]; then + cat ap_folded_"$pid" + else + echo "Error: async-profiler output file not found for PID $pid" >&2 + fi +done `, - Superuser: true, - Depends: []string{"perf", "stackcollapse-perf.pl"}, + Superuser: true, + Sequential: true, + Depends: []string{"async-profiler", "perf", "stackcollapse-perf"}, }, // lock analysis scripts ProfileKernelLockScriptName: { diff --git a/internal/util/util.go b/internal/util/util.go index c2176719..d76f7df4 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -521,3 +521,12 @@ func SelectiveIntRangeToIntList(input string) ([]int, error) { } return result, nil } + +// IntSliceToStringSlice converts a slice of integers to a slice of strings. +func IntSliceToStringSlice(ints []int) []string { + strs := make([]string, len(ints)) + for i, v := range ints { + strs[i] = strconv.Itoa(v) + } + return strs +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go index 17adc27e..7ce36a83 100644 --- a/internal/util/util_test.go +++ b/internal/util/util_test.go @@ -149,3 +149,22 @@ func TestSelectiveIntRangeToIntList(t *testing.T) { } } } +func TestIntSliceToStringSlice(t *testing.T) { + tests := []struct { + input []int + expected []string + }{ + {[]int{1, 2, 3}, []string{"1", "2", "3"}}, // Simple case + {[]int{-1, 0, 1}, []string{"-1", "0", "1"}}, // Negative, zero, and positive integers + {[]int{}, []string{}}, // Empty slice + {[]int{123, 456, 789}, []string{"123", "456", "789"}}, // Larger numbers + {[]int{-123, -456, -789}, []string{"-123", "-456", "-789"}}, // Negative larger numbers + } + + for _, test := range tests { + result := IntSliceToStringSlice(test.input) + if !slices.Equal(result, test.expected) { + t.Errorf("expected %v, got %v for input %v", test.expected, result, test.input) + } + } +} diff --git a/tools/Makefile b/tools/Makefile index afb59d72..a39bc266 100644 --- a/tools/Makefile +++ b/tools/Makefile @@ -5,9 +5,9 @@ # default: tools -.PHONY: default tools async-profiler avx-turbo cpuid dmidecode ethtool fio flamegraph ipmitool lshw lspci msr-tools pcm perf processwatch spectre-meltdown-checker sshpass stress-ng sysstat tsc turbostat +.PHONY: default tools async-profiler avx-turbo cpuid dmidecode ethtool fio ipmitool lshw lspci msr-tools pcm perf processwatch spectre-meltdown-checker sshpass stackcollapse-perf stress-ng sysstat tsc turbostat -tools: async-profiler avx-turbo cpuid dmidecode ethtool fio flamegraph ipmitool lshw lspci msr-tools pcm spectre-meltdown-checker sshpass stress-ng sysstat tsc turbostat +tools: async-profiler avx-turbo cpuid dmidecode ethtool fio ipmitool lshw lspci msr-tools pcm spectre-meltdown-checker sshpass stackcollapse-perf stress-ng sysstat tsc turbostat mkdir -p bin cp -R async-profiler bin/ cp avx-turbo/avx-turbo bin/ @@ -15,7 +15,7 @@ tools: async-profiler avx-turbo cpuid dmidecode ethtool fio flamegraph ipmitool cp dmidecode/dmidecode bin/ cp ethtool/ethtool bin/ cp fio/fio bin/ - cp flamegraph/stackcollapse-perf.pl bin/ + cp stackcollapse-perf/stackcollapse-perf bin/ cp ipmitool/src/ipmitool.static bin/ipmitool cp lshw/src/lshw-static bin/lshw cp lspci/lspci bin/ @@ -103,14 +103,6 @@ ifeq ("$(wildcard fio/config.log)","") endif cd fio && make -FLAMEGRAPH_VERSION := "master" -flamegraph: -ifeq ("$(wildcard flamegraph)","") - git clone https://github.com/brendangregg/FlameGraph.git flamegraph - # small modification to script to include module name in output - cd flamegraph && sed -i '382 a \\t\t\t\t$$func = \$$func."'" "'".\$$mod;\t# add module name' stackcollapse-perf.pl -endif - IPMITOOL_VERSION := "IPMITOOL_1_8_19" ipmitool: ifeq ("$(wildcard ipmitool)","") @@ -213,6 +205,9 @@ ifeq ("$(wildcard sshpass)","") endif cd sshpass && make +stackcollapse-perf: + cd stackcollapse-perf && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build + STRESS_NG_VERSION := "V0.13.08" stress-ng: ifeq ("$(wildcard stress-ng)","") @@ -252,7 +247,6 @@ reset: cd dmidecode && git clean -fdx && git reset --hard cd ethtool && git clean -fdx && git reset --hard cd fio && git clean -fdx && git reset --hard - cd flamegraph && git clean -fdx && git reset --hard cd ipmitool && git clean -fdx && git reset --hard cd lshw && git clean -fdx && git reset --hard cd lspci && git clean -fdx && git reset --hard @@ -275,5 +269,5 @@ libcrypt.tar.gz: libs: glibc-2.19.tar.bz2 zlib.tar.gz libcrypt.tar.gz oss-source: reset libs - tar --exclude-vcs -czf oss_source.tgz async-profiler/ cpuid/ dmidecode/ ethtool/ fio/ flamegraph/ ipmitool/ lshw/ lspci/ msr-tools/ pcm/ spectre-meltdown-checker/ sshpass/ stress-ng/ sysstat/ linux_turbostat/tools/power/x86/turbostat glibc-2.19.tar.bz2 zlib.tar.gz libcrypt.tar.gz + tar --exclude-vcs -czf oss_source.tgz async-profiler/ cpuid/ dmidecode/ ethtool/ fio/ ipmitool/ lshw/ lspci/ msr-tools/ pcm/ spectre-meltdown-checker/ sshpass/ stress-ng/ sysstat/ linux_turbostat/tools/power/x86/turbostat glibc-2.19.tar.bz2 zlib.tar.gz libcrypt.tar.gz md5sum oss_source.tgz > oss_source.tgz.md5 diff --git a/tools/stackcollapse-perf/go.mod b/tools/stackcollapse-perf/go.mod new file mode 100644 index 00000000..b3cd282a --- /dev/null +++ b/tools/stackcollapse-perf/go.mod @@ -0,0 +1,3 @@ +module intel.com/stackcollapse-perf + +go 1.24 diff --git a/tools/stackcollapse-perf/stackcollapse-perf.go b/tools/stackcollapse-perf/stackcollapse-perf.go new file mode 100644 index 00000000..e6bf29b8 --- /dev/null +++ b/tools/stackcollapse-perf/stackcollapse-perf.go @@ -0,0 +1,328 @@ +package main + +// Copyright (C) 2021-2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause + +// This code is a port of the Perl script stackcollapse-perf.pl from Brendan +// Gregg's Flamegraph project -- github.com/brendangregg/FlameGraph. +// All credit to Brendan Gregg for the original implementation and the flamegraph concept. + +import ( + "bufio" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// Config holds configuration options for processing stacks. +// It includes options for annotating kernel and JIT symbols, including process names, PIDs, TIDs, and addresses, +// as well as options for tidying Java and generic function names, filtering events, and showing inline or context information. +type Config struct { + AnnotateKernel bool + AnnotateJit bool + IncludePname bool + IncludePid bool + IncludeTid bool + IncludeAddrs bool + TidyJava bool + TidyGeneric bool + EventFilter string + ShowInline bool + ShowContext bool + SrcLineInInput bool +} + +// StackAggregator aggregates stack traces and their counts. +// It provides a method to remember stacks and their associated counts. +type StackAggregator struct { + collapsed map[string]int +} + +// NewStackAggregator creates and returns a new StackAggregator instance. +func NewStackAggregator() *StackAggregator { + return &StackAggregator{collapsed: make(map[string]int)} +} + +// RememberStack adds a stack trace and its count to the aggregator. +func (sa *StackAggregator) RememberStack(stack string, count int) { + sa.collapsed[stack] += count +} + +func main() { + var config Config + + flag.BoolVar(&config.AnnotateKernel, "kernel", false, "annotate kernel functions with a _[k]") + flag.BoolVar(&config.AnnotateJit, "jit", false, "annotate jit functions with a _[j]") + var annotateAll bool + flag.BoolVar(&annotateAll, "all", false, "all annotations (--kernel --jit)") + flag.BoolVar(&config.IncludePname, "pname", true, "include process names in stacks") + flag.BoolVar(&config.IncludePid, "pid", false, "include PID with process names") + flag.BoolVar(&config.IncludeTid, "tid", false, "include TID and PID with process names") + flag.BoolVar(&config.IncludeAddrs, "addrs", false, "include raw addresses where symbols can't be found") + flag.BoolVar(&config.TidyJava, "java", true, "condense Java signatures") + flag.BoolVar(&config.TidyGeneric, "generic", true, "clean up function names a little") + flag.StringVar(&config.EventFilter, "event-filter", "", "event name filter") + flag.BoolVar(&config.ShowInline, "inline", false, "un-inline using addr2line") + flag.BoolVar(&config.ShowContext, "context", false, "adds source context to --inline") + flag.BoolVar(&config.SrcLineInInput, "srcline", false, "parses output of 'perf script -F+srcline' and adds source context") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "USAGE: %s [options] [infile] > outfile\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\nOptions:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\n[1] perf script must emit both PID and TIDs for these to work; eg, Linux < 4.1:\n") + fmt.Fprintf(os.Stderr, " perf script -f comm,pid,tid,cpu,time,event,ip,sym,dso,trace\n") + fmt.Fprintf(os.Stderr, " for Linux >= 4.1:\n") + fmt.Fprintf(os.Stderr, " perf script -F comm,pid,tid,cpu,time,event,ip,sym,dso,trace\n") + fmt.Fprintf(os.Stderr, " If you save this output add --header on Linux >= 3.14 to include perf info.\n") + } + + flag.Parse() + + if annotateAll { + config.AnnotateKernel = true + config.AnnotateJit = true + } + + if config.ShowInline { + fmt.Fprintf(os.Stderr, "--inline is not implemented\n") + os.Exit(1) + } + if config.SrcLineInInput { + fmt.Fprintf(os.Stderr, "--srcline is not implemented\n") + os.Exit(1) + } + + var input *os.File + var err error + + args := flag.Args() + if len(args) > 0 { + input, err = os.Open(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening file: %s\n", err) + os.Exit(1) + } + defer input.Close() + } else { + input = os.Stdin + } + + err = ProcessStacks(input, os.Stdout, config) + if err != nil { + fmt.Fprintf(os.Stderr, "Error processing stacks: %s\n", err) + os.Exit(1) + } +} + +// Regular expressions for parsing the perf output +var ( + eventLineRegex = regexp.MustCompile(`^(\S.+?)\s+(\d+)\/*(\d+)*\s+`) + eventTypeRegex = regexp.MustCompile(`:\s*(\d+)*\s+(\S+):\s*$`) + stackLineRegex = regexp.MustCompile(`^\s*(\w+)\s*(.+) \((.*)\)`) + stripSymbolOffsetRegex = regexp.MustCompile(`\+0x[\da-f]+$`) + goMethodRegex = regexp.MustCompile(`\.\(.*\)\.`) + jitRegex = regexp.MustCompile(`/tmp/perf-\d+\.map`) +) + +// ProcessStacks processes stack traces from the input reader and writes the collapsed stacks to the output writer. +// It uses the provided configuration to control the processing behavior. +func ProcessStacks(input io.Reader, output io.Writer, config Config) error { + var stack []string + var processName string + var period int + aggregator := NewStackAggregator() + scanner := bufio.NewScanner(input) + + // main loop, read lines from stdin + for scanner.Scan() { + line := scanner.Text() + // skip comments + if strings.HasPrefix(line, "#") { + continue + } + // skip empty lines that are not after a stack + if line == "" && processName == "" { + continue + } + // check for end of stack + if line == "" { + if config.IncludePname { + stack = append([]string{processName}, stack...) + } + if stack != nil { + aggregator.RememberStack(strings.Join(stack, ";"), period) + } + stack = nil + processName = "" + continue + } + // check for event record + if eventLineRegex.MatchString(line) { + var err error + processName, period, err = handleEventRecord(line, config) + if err != nil { + fmt.Fprintf(output, "Error: %s\n", err) + } + continue + } + // check for stack line + if stackLineRegex.MatchString(line) { + err := handleStackLine(line, &stack, processName, config) + if err != nil { + fmt.Fprintf(output, "Error: %s\n", err) + } + continue + } + } + // Check for errors during scanning + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "Error reading input: %s\n", err) + return err + } + // Output results + keys := make([]string, 0, len(aggregator.collapsed)) + for k := range aggregator.collapsed { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(output, "%s %d\n", k, aggregator.collapsed[k]) + } + return nil +} + +// handleEventRecord parses an event record line and updates the process name and period based on the configuration. +func handleEventRecord(line string, config Config) (processName string, period int, err error) { + matches := eventLineRegex.FindStringSubmatch(line) + if matches == nil { + return + } + + comm, pid, tid := matches[1], matches[2], matches[3] + if tid == "" { + tid = pid + pid = "?" + } + + if eventMatches := eventTypeRegex.FindStringSubmatch(line); eventMatches != nil { + eventPeriod := eventMatches[1] + if eventPeriod == "" { + period = 1 + } else { + var eventPeriodInt int + eventPeriodInt, err = strconv.Atoi(eventPeriod) + if err != nil { + err = fmt.Errorf("failed to parse event period: %s, error: %v", eventPeriod, err) + return + } + period = eventPeriodInt + } + event := eventMatches[2] + + if config.EventFilter == "" { + config.EventFilter = event + } else if event != config.EventFilter { + err = fmt.Errorf("event type mismatch: %s != %s", event, config.EventFilter) + return + } + } + + if config.IncludeTid { + processName = fmt.Sprintf("%s-%s/%s", comm, pid, tid) + } else if config.IncludePid { + processName = fmt.Sprintf("%s-%s", comm, pid) + } else { + processName = comm + } + processName = strings.ReplaceAll(processName, " ", "_") + return +} + +// handleStackLine parses a stack line and appends the function name to the stack based on the configuration. +func handleStackLine(line string, stack *[]string, pname string, config Config) error { + matches := stackLineRegex.FindStringSubmatch(line) + if matches == nil || pname == "" { + return nil + } + + pc, rawFunc, mod := matches[1], matches[2], matches[3] + + rawFunc = stripSymbolOffsetRegex.ReplaceAllString(rawFunc, "") + + // skip process names + if strings.HasPrefix(rawFunc, "(") { + return nil + } + + *stack = append(processFunctionName(rawFunc, mod, pc, config), *stack...) + return nil +} + +// processFunctionName processes a raw function name, module, and program counter (PC) based on the configuration. +// It returns a slice of processed function names. +func processFunctionName(rawFunc, mod, pc string, config Config) []string { + var inline []string + for funcname := range strings.SplitSeq(rawFunc, "->") { + if funcname == "[unknown]" { // use module name instead, if known + if mod != "[unknown]" { + funcname = filepath.Base(mod) + } else { + funcname = "unknown" + } + + if config.IncludeAddrs { + funcname = fmt.Sprintf("[%s <%s>]", funcname, pc) + } else { + funcname = fmt.Sprintf("[%s]", funcname) + } + } + if config.TidyGeneric { + funcname = strings.ReplaceAll(funcname, ";", ":") + if !goMethodRegex.MatchString(funcname) { + funcname = stripParenArgsUnlessAnonymous(funcname) + } + funcname = strings.ReplaceAll(funcname, "\"", "") + funcname = strings.ReplaceAll(funcname, "'", "") + } + if config.TidyJava { + if strings.Contains(funcname, "/") { + funcname = strings.TrimPrefix(funcname, "L") + } + } + // annotations + if len(inline) > 0 { + if !strings.Contains(funcname, "_[i]") { + funcname = fmt.Sprintf("%s_[i]", funcname) + } + } else if config.AnnotateKernel && (strings.HasPrefix(mod, "[") || strings.HasSuffix(mod, "vmlinux")) && !strings.Contains(mod, "unknown") { + funcname = fmt.Sprintf("%s_[k]", funcname) + } else if config.AnnotateJit && jitRegex.MatchString(mod) { + if !strings.Contains(funcname, "_[j]") { + funcname = fmt.Sprintf("%s_[j]", funcname) + } + } + + inline = append(inline, funcname) + } + return inline +} + +// stripParenArgsUnlessAnonymous removes everything after the first '(' unless it is immediately followed by 'anonymous namespace'. +func stripParenArgsUnlessAnonymous(s string) string { + idx := strings.Index(s, "(") + if idx == -1 { + return s + } + // Check if what follows is 'anonymous namespace' + if strings.HasPrefix(s[idx:], "(anonymous namespace") { + return s + } + return s[:idx] +} diff --git a/tools/stackcollapse-perf/stackcollapse-perf_test.go b/tools/stackcollapse-perf/stackcollapse-perf_test.go new file mode 100644 index 00000000..acc38ec5 --- /dev/null +++ b/tools/stackcollapse-perf/stackcollapse-perf_test.go @@ -0,0 +1,192 @@ +package main + +// Copyright (C) 2021-2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause + +import ( + "bytes" + "strings" + "testing" +) + +// keep for debugging +// func TestProcessStacksFromFile(t *testing.T) { +// filePath := filepath.Join("testdata", "perf_fp_stacks") +// file, err := os.Open(filePath) +// if err != nil { +// t.Fatalf("failed to open test file: %v", err) +// } +// defer file.Close() + +// output := &bytes.Buffer{} +// config := Config{ +// IncludePname: true, +// IncludePid: false, +// IncludeTid: false, +// IncludeAddrs: false, +// TidyJava: true, +// TidyGeneric: true, +// } + +// err = ProcessStacks(file, output, config) +// if err != nil { +// t.Fatalf("unexpected error: %v", err) +// } + +// if output.Len() == 0 { +// t.Errorf("expected output, got empty result") +// } +// } + +func TestProcessStacks(t *testing.T) { + input := strings.NewReader(` +stress-ng-cpu 1230556 [121] 6223127.073349: 293637623 cycles:P: + 61e248df6091 [unknown] (/usr/bin/stress-ng) + +stress-ng-cpu 1230793 [098] 6223127.074783: 307465331 cycles:P: + ffffffffa7c00f0b asm_sysvec_apic_timer_interrupt+0x1b ([kernel.kallsyms]) + 760c9702dc5d [unknown] (/usr/lib/x86_64-linux-gnu/libm.so.6) + 760c96fda3a2 sincosf64x+0x122 (/usr/lib/x86_64-linux-gnu/libm.so.6) + + `) + output := &bytes.Buffer{} + + config := Config{ + IncludePname: true, + IncludePid: false, + IncludeTid: false, + IncludeAddrs: false, + TidyJava: true, + TidyGeneric: true, + } + + err := ProcessStacks(input, output, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "stress-ng-cpu;[stress-ng] 293637623\nstress-ng-cpu;sincosf64x;[libm.so.6];asm_sysvec_apic_timer_interrupt 307465331\n" + if output.String() != expected { + t.Errorf("expected %q, got %q", expected, output.String()) + } +} + +func TestHandleEventRecord(t *testing.T) { + line := "stress-ng-cpu 1230556 [121] 6223127.073349: 293637623 cycles:P: " + var processName string + var period int + config := Config{ + IncludePname: true, + IncludePid: false, + IncludeTid: false, + IncludeAddrs: false, + TidyJava: true, + TidyGeneric: true, + } + + processName, period, err := handleEventRecord(line, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedProcessName := "stress-ng-cpu" + if processName != "stress-ng-cpu" { + t.Errorf("expected processName to be '%s', got %q", expectedProcessName, processName) + } + expectedPeriod := 293637623 + if period != expectedPeriod { + t.Errorf("expected period to be %d, got %d", expectedPeriod, period) + } +} + +func TestHandleStackLine(t *testing.T) { + line := "0x1234 someFunction (module)" + var stack []string + processName := "main" + config := Config{ + IncludePname: true, + IncludePid: false, + IncludeTid: false, + IncludeAddrs: false, + TidyJava: true, + TidyGeneric: true, + } + + err := handleStackLine(line, &stack, processName, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(stack) != 1 || stack[0] != "someFunction" { + t.Errorf("expected stack to contain 'someFunction', got %v", stack) + } +} + +func TestProcessFunctionName(t *testing.T) { + tests := []struct { + rawFunc string + mod string + pc string + config Config + expected []string + }{ + { + rawFunc: "[unknown]", + mod: "[kernel]", + pc: "0x1234", + config: Config{IncludeAddrs: true}, + expected: []string{"[[kernel] <0x1234>]"}, + }, + { + rawFunc: "Lcom/example/MyClass", + mod: "[unknown]", + pc: "0x5678", + config: Config{TidyJava: true}, + expected: []string{"com/example/MyClass"}, + }, + { + rawFunc: "someFunction;bar\"hello'world(remove me).foo", + mod: "module.so", + pc: "0x9abc", + config: Config{TidyGeneric: true}, + expected: []string{"someFunction:barhelloworld"}, + }, + } + + for _, test := range tests { + result := processFunctionName(test.rawFunc, test.mod, test.pc, test.config) + if len(result) != len(test.expected) { + t.Errorf("expected %d results, got %d", len(test.expected), len(result)) + continue + } + for i := range result { + if result[i] != test.expected[i] { + t.Errorf("expected %q, got %q", test.expected[i], result[i]) + } + } + } +} +func TestStripParenArgsUnlessAnonymous(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"foo(int, float)", "foo"}, + {"bar()", "bar"}, + {"baz", "baz"}, + {"qux(anonymous namespace)", "qux(anonymous namespace)"}, + {"func(anonymous namespace::Type)", "func(anonymous namespace::Type)"}, + {"func(anonymous namespace", "func(anonymous namespace"}, + {"func(int) (anonymous namespace)", "func"}, + {"func()", "func"}, + {"func", "func"}, + {"func(abc", "func"}, + } + + for _, test := range tests { + result := stripParenArgsUnlessAnonymous(test.input) + if result != test.expected { + t.Errorf("stripParenArgsUnlessAnonymous(%q) = %q; want %q", test.input, result, test.expected) + } + } +}