diff --git a/go.mod b/go.mod index db65a4d950..4ad72c6f72 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.9 require ( github.com/chzyer/readline v1.5.1 github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b + golang.org/x/mod v0.31.0 ) require golang.org/x/sys v0.32.0 // indirect diff --git a/go.sum b/go.sum index fb7c0ef583..35f7a014ce 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b h1:ogbOPx86mIhFy764gGkqnkFC8m5PJA7sPzlk9ppLVQA= github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/internal/report/report.go b/internal/report/report.go index ad8b84bf80..04826b3125 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -20,8 +20,10 @@ import ( "fmt" "io" "net/url" + "os" "path/filepath" "regexp" + "runtime" "sort" "strconv" "strings" @@ -84,6 +86,28 @@ type Options struct { IntelSyntax bool // Whether or not to print assembly in Intel syntax. } +func (o *Options) sourcePaths() []string { + sourcePaths := filepath.SplitList(o.SourcePath) + if len(sourcePaths) == 0 { + // Search for source files in the current directory by default. + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + sourcePaths = []string{cwd} + } + + // Always search for source files in $GOROOT/src (aka standard packages) + // as a fallback. + sourcePaths = append(sourcePaths, filepath.Join(runtime.GOROOT(), "src")) + + return sourcePaths +} + +func (o *Options) trimPaths() []string { + return filepath.SplitList(o.TrimPath) +} + // Generate generates a report as directed by the Report. func Generate(w io.Writer, rpt *Report, obj plugin.ObjTool) error { o := rpt.options @@ -240,9 +264,12 @@ func (rpt *Report) newGraph(nodes graph.NodeSet) *graph.Graph { // Clean up file paths using heuristics. prof := rpt.prof + sourcePaths := o.sourcePaths() + trimPaths := o.trimPaths() for _, f := range prof.Function { - f.Filename = trimPath(f.Filename, o.TrimPath, o.SourcePath) + f.Filename = sourceFilename(f.Filename, sourcePaths, trimPaths) } + // Removes all numeric tags except for the bytes tag prior // to making graph. // TODO: modify to select first numeric tag if no bytes tag @@ -293,6 +320,27 @@ func (rpt *Report) newGraph(nodes graph.NodeSet) *graph.Graph { return graph.New(rpt.prof, gopt) } +// sourceFilename returns filename path to the given path. +func sourceFilename(path string, sourcePaths, trimPaths []string) string { + f := tryOpenSourceFile(path, sourcePaths, trimPaths) + if f == nil { + return path + } + filename := f.Name() + _ = f.Close() + + // Trim current directory from filename for simpler readability + wd, err := os.Getwd() + if err == nil { + if !strings.HasSuffix(wd, string(filepath.Separator)) { + wd += string(filepath.Separator) + } + filename = strings.TrimPrefix(filename, wd) + } + + return filename +} + // printProto writes the incoming proto via the writer w. // If the divide_by option has been specified, samples are scaled appropriately. func printProto(w io.Writer, rpt *Report) error { diff --git a/internal/report/source.go b/internal/report/source.go index f17952faee..828021aa0f 100644 --- a/internal/report/source.go +++ b/internal/report/source.go @@ -34,6 +34,7 @@ import ( "github.com/google/pprof/internal/measurement" "github.com/google/pprof/internal/plugin" "github.com/google/pprof/profile" + "golang.org/x/mod/module" ) // printSource prints an annotated source listing, include all @@ -63,15 +64,7 @@ func printSource(w io.Writer, rpt *Report) error { return fmt.Errorf("no matches found for regexp: %s", o.Symbol) } - sourcePath := o.SourcePath - if sourcePath == "" { - wd, err := os.Getwd() - if err != nil { - return fmt.Errorf("could not stat current dir: %v", err) - } - sourcePath = wd - } - reader := newSourceReader(sourcePath, o.TrimPath) + reader := newSourceReader(o) fmt.Fprintf(w, "Total: %s\n", rpt.formatValue(rpt.total)) for _, fn := range functions { @@ -100,11 +93,12 @@ func printSource(w io.Writer, rpt *Report) error { // Print each file associated with this function. for _, fl := range sourceFiles { - filename := fl.Info.File - fns := fileNodes[filename] + path := fl.Info.File + fns := fileNodes[path] flatSum, cumSum := fns.Sum() - fnodes, _, err := getSourceFromFile(filename, reader, fns, 0, 0) + fnodes, _, err := getSourceFromFile(path, reader, fns, 0, 0) + filename := sourceFilename(path, reader.sourcePaths, reader.trimPaths) fmt.Fprintf(w, "ROUTINE ======================== %s in %s\n", name, filename) fmt.Fprintf(w, "%10s %10s (flat, cum) %s of Total\n", rpt.formatValue(flatSum), rpt.formatValue(cumSum), @@ -241,15 +235,7 @@ type WebListCall struct { // MakeWebList returns an annotated source listing of rpt. // rpt.prof should contain inlined call info. func MakeWebList(rpt *Report, obj plugin.ObjTool, maxFiles int) (WebListData, error) { - sourcePath := rpt.options.SourcePath - if sourcePath == "" { - wd, err := os.Getwd() - if err != nil { - return WebListData{}, fmt.Errorf("could not stat current dir: %v", err) - } - sourcePath = wd - } - sp := newSourcePrinter(rpt, obj, sourcePath) + sp := newSourcePrinter(rpt, obj) if len(sp.interest) == 0 { return WebListData{}, fmt.Errorf("no matches found for regexp: %s", rpt.options.Symbol) } @@ -257,9 +243,9 @@ func MakeWebList(rpt *Report, obj plugin.ObjTool, maxFiles int) (WebListData, er return sp.generate(maxFiles, rpt), nil } -func newSourcePrinter(rpt *Report, obj plugin.ObjTool, sourcePath string) *sourcePrinter { +func newSourcePrinter(rpt *Report, obj plugin.ObjTool) *sourcePrinter { sp := &sourcePrinter{ - reader: newSourceReader(sourcePath, rpt.options.TrimPath), + reader: newSourceReader(rpt.options), synth: newSynthCode(rpt.prof.Mapping), objectTool: obj, objects: map[string]plugin.ObjFile{}, @@ -941,12 +927,11 @@ func getSourceFromFile(file string, reader *sourceReader, fns graph.Nodes, start // sourceReader provides access to source code with caching of file contents. type sourceReader struct { - // searchPath is a filepath.ListSeparator-separated list of directories where - // source files should be searched. - searchPath string + // sourcePaths is a list of directories where source files should be searched. + sourcePaths []string - // trimPath is a filepath.ListSeparator-separated list of paths to trim. - trimPath string + // trimPaths is a list of path prefixes to trim. + trimPaths []string // files maps from path name to a list of lines. // files[*][0] is unused since line numbering starts at 1. @@ -957,12 +942,12 @@ type sourceReader struct { errors map[string]error } -func newSourceReader(searchPath, trimPath string) *sourceReader { +func newSourceReader(options *Options) *sourceReader { return &sourceReader{ - searchPath, - trimPath, - make(map[string][]string), - make(map[string]error), + sourcePaths: options.sourcePaths(), + trimPaths: options.trimPaths(), + files: make(map[string][]string), + errors: make(map[string]error), } } @@ -976,8 +961,9 @@ func (reader *sourceReader) line(path string, lineno int) (string, bool) { if !ok { // Read and cache file contents. lines = []string{""} // Skip 0th line - f, err := openSourceFile(path, reader.searchPath, reader.trimPath) - if err != nil { + f := tryOpenSourceFile(path, reader.sourcePaths, reader.trimPaths) + if f == nil { + err := fmt.Errorf("could not find %s at %s", path, strings.Join(reader.sourcePaths, string(filepath.ListSeparator))) reader.errors[path] = err } else { s := bufio.NewScanner(f) @@ -985,7 +971,7 @@ func (reader *sourceReader) line(path string, lineno int) (string, bool) { lines = append(lines, s.Text()) } f.Close() - if s.Err() != nil { + if err := s.Err(); err != nil { reader.errors[path] = err } } @@ -997,62 +983,89 @@ func (reader *sourceReader) line(path string, lineno int) (string, bool) { return lines[lineno], true } -// openSourceFile opens a source file from a name encoded in a profile. File -// names in a profile after can be relative paths, so search them in each of -// the paths in searchPath and their parents. In case the profile contains -// absolute paths, additional paths may be configured to trim from the source -// paths in the profile. This effectively turns the path into a relative path -// searching it using searchPath as usual). -func openSourceFile(path, searchPath, trim string) (*os.File, error) { - path = trimPath(path, trim, searchPath) - // If file is still absolute, require file to exist. +// tryOpenSourceFile opens a source file from the path encoded in a profile. +func tryOpenSourceFile(path string, sourcePaths, trimPaths []string) *os.File { + path = trimPathPrefix(path, trimPaths) + if filepath.IsAbs(path) { - f, err := os.Open(path) - return f, err + if f := tryOpenFile(path); f != nil { + return f + } } - // Scan each component of the path. - for _, dir := range filepath.SplitList(searchPath) { - // Search up for every parent of each possible path. - for { + + vendoredPath := getVendoredPath(path) + + for _, dir := range sourcePaths { + if !filepath.IsAbs(path) { + // Try opening the path at dir. filename := filepath.Join(dir, path) - if f, err := os.Open(filename); err == nil { - return f, nil + if f := tryOpenFile(filename); f != nil { + return f } - parent := filepath.Dir(dir) - if parent == dir { + + // Try opening the path at dir/vendor. + filename = filepath.Join(dir, "vendor", vendoredPath) + if f := tryOpenFile(filename); f != nil { + return f + } + + } + + // The path may contain arbitrary prefix, which doesn't match the dir. + // Try reading the file at the dir with trimmed path prefixes. + pathWithoutVolume := strings.TrimPrefix(path, filepath.VolumeName(path)) + pathTrimmed := strings.TrimPrefix(pathWithoutVolume, string(filepath.Separator)) + for { + filename := filepath.Join(dir, pathTrimmed) + if f := tryOpenFile(filename); f != nil { + return f + } + + n := strings.IndexByte(pathTrimmed, filepath.Separator) + if n < 0 { break } - dir = parent + pathTrimmed = pathTrimmed[n+1:] } } - return nil, fmt.Errorf("could not find file %s on path %s", path, searchPath) -} - -// trimPath cleans up a path by removing prefixes that are commonly -// found on profiles plus configured prefixes. -// TODO(aalexand): Consider optimizing out the redundant work done in this -// function if it proves to matter. -func trimPath(path, trimPath, searchPath string) string { - // Keep path variable intact as it's used below to form the return value. - sPath, searchPath := filepath.ToSlash(path), filepath.ToSlash(searchPath) - if trimPath == "" { - // If the trim path is not configured, try to guess it heuristically: - // search for basename of each search path in the original path and, if - // found, strip everything up to and including the basename. So, for - // example, given original path "/some/remote/path/my-project/foo/bar.c" - // and search path "/my/local/path/my-project" the heuristic will return - // "/my/local/path/my-project/foo/bar.c". - for _, dir := range filepath.SplitList(searchPath) { - want := "/" + filepath.Base(dir) + "/" - if found := strings.Index(sPath, want); found != -1 { - return path[found+len(want):] + if !filepath.IsAbs(path) { + // Try opening the path at $GOMODCACHE + if modcacheDir := getGomodCacheDir(); modcacheDir != "" { + modcachePath := getGomodPath(path) + filename := filepath.Join(modcacheDir, modcachePath) + if f := tryOpenFile(filename); f != nil { + return f } } } - // Trim configured trim prefixes. - trimPaths := append(filepath.SplitList(filepath.ToSlash(trimPath)), "/proc/self/cwd/./", "/proc/self/cwd/") + + return nil +} + +// getGomodCacheDir returns GOMODCACHE value. +// +// See https://go.dev/ref/mod#module-cache . +func getGomodCacheDir() string { + if v, ok := os.LookupEnv("GOMODCACHE"); ok { + return v + } + if v, ok := os.LookupEnv("GOPATH"); ok { + return filepath.Join(v, "pkg", "mod") + } + if v, ok := os.LookupEnv("HOME"); ok { + return filepath.Join(v, "go", "pkg", "mod") + } + return "" +} + +// trimPathPrefix cleans up a path by removing trimPaths prefixes. +func trimPathPrefix(path string, trimPaths []string) string { + // Keep path variable intact as it's used below to form the return value. + sPath := filepath.ToSlash(path) + for _, trimPath := range trimPaths { + trimPath = filepath.ToSlash(trimPath) if !strings.HasSuffix(trimPath, "/") { trimPath += "/" } @@ -1063,6 +1076,67 @@ func trimPath(path, trimPath, searchPath string) string { return path } +// getGomodPath returns the path under GOMODCACHE for the given path. +func getGomodPath(path string) string { + n := strings.IndexByte(path, '@') + if n < 0 { + return path + } + gomodPath := path[:n] + + tail := "" + gomodVersion := path[n+1:] + if n := strings.IndexByte(gomodVersion, filepath.Separator); n >= 0 { + tail = gomodVersion[n:] + gomodVersion = gomodVersion[:n] + } + + gomodPathEscaped, err := module.EscapePath(gomodPath) + if err != nil { + gomodPathEscaped = gomodPath + } + gomodVersionEscaped, err := module.EscapeVersion(gomodVersion) + if err != nil { + gomodVersionEscaped = gomodVersion + } + return gomodPathEscaped + "@" + gomodVersionEscaped + tail +} + +// getVendoredPath strips @v... from the repo@v.../package path. +func getVendoredPath(path string) string { + n := strings.IndexByte(path, '@') + if n < 0 { + return path + } + prefix := path[:n] + suffix := path[n+1:] + n = strings.IndexByte(suffix, filepath.Separator) + if n < 0 { + return prefix + } + return prefix + suffix[n:] +} + +func tryOpenFile(filename string) *os.File { + if filename == "" { + return nil + } + f, err := os.Open(filename) + if err != nil { + return nil + } + stat, err := f.Stat() + if err != nil { + _ = f.Close() + return nil + } + if stat.IsDir() { + _ = f.Close() + return nil + } + return f +} + func indentation(line string) int { column := 0 for _, c := range line { diff --git a/internal/report/source_test.go b/internal/report/source_test.go index afd166b396..9cccabb78f 100644 --- a/internal/report/source_test.go +++ b/internal/report/source_test.go @@ -120,7 +120,7 @@ func testSourceMapping(t *testing.T, zeroAddress bool) { } } -func TestOpenSourceFile(t *testing.T) { +func TestSourceFilename(t *testing.T) { tempdir, err := os.MkdirTemp("", "") if err != nil { t.Fatalf("failed to create temp dir: %v", err) @@ -128,57 +128,144 @@ func TestOpenSourceFile(t *testing.T) { const lsep = string(filepath.ListSeparator) for _, tc := range []struct { desc string - searchPath string + sourcePath string trimPath string fs []string path string wantPath string // If empty, error is wanted. }{ { - desc: "exact absolute path is found", + desc: "exact absolute path", fs: []string{"foo/bar.cc"}, path: "$dir/foo/bar.cc", wantPath: "$dir/foo/bar.cc", }, { - desc: "exact relative path is found", - searchPath: "$dir", + desc: "exact absolute path not found", + fs: []string{"abc.cc"}, + path: "/aaa/foo/bar.cc", + wantPath: "/aaa/foo/bar.cc", + }, + { + desc: "exact relative path", + sourcePath: "$dir", fs: []string{"foo/bar.cc"}, path: "foo/bar.cc", wantPath: "$dir/foo/bar.cc", }, + { + desc: "exact relative path not found", + sourcePath: "$dir", + fs: []string{"baz.cc"}, + path: "foo/bar.cc", + wantPath: "foo/bar.cc", + }, + { + desc: "exact relative path in vendor", + sourcePath: "$dir", + fs: []string{"vendor/foo/bar.cc"}, + path: "foo/bar.cc", + wantPath: "$dir/vendor/foo/bar.cc", + }, + { + desc: "exact relative path in vendor not found", + sourcePath: "$dir", + fs: []string{"vendor/bar.cc"}, + path: "foo/bar.cc", + wantPath: "foo/bar.cc", + }, + { + desc: "exact relative path with module version in vendor", + sourcePath: "$dir", + fs: []string{"vendor/foo/bar.cc"}, + path: "foo@v1.2.3/bar.cc", + wantPath: "$dir/vendor/foo/bar.cc", + }, + { + desc: "exact relative path with module version in vendor not found", + sourcePath: "$dir", + fs: []string{"vendor/bar.cc"}, + path: "foo@v1.2.3/bar.cc", + wantPath: "foo@v1.2.3/bar.cc", + }, { desc: "multiple search path", - searchPath: "some/path" + lsep + "$dir", + sourcePath: "some/path" + lsep + "$dir", fs: []string{"foo/bar.cc"}, path: "foo/bar.cc", wantPath: "$dir/foo/bar.cc", }, - { - desc: "relative path is found in parent dir", - searchPath: "$dir/foo/bar", - fs: []string{"bar.cc", "foo/bar/baz.cc"}, - path: "bar.cc", - wantPath: "$dir/bar.cc", - }, { desc: "trims configured prefix", - searchPath: "$dir", + sourcePath: "$dir", trimPath: "some-path" + lsep + "/some/remote/path", fs: []string{"my-project/foo/bar.cc"}, path: "/some/remote/path/my-project/foo/bar.cc", wantPath: "$dir/my-project/foo/bar.cc", }, { - desc: "trims heuristically", - searchPath: "$dir/my-project", + desc: "trims configured prefix not found", + sourcePath: "$dir", + trimPath: "/some/remote/path", + fs: []string{"my-project/foo/baz.cc"}, + path: "/some/remote/path/my-project/foo/bar.cc", + wantPath: "/some/remote/path/my-project/foo/bar.cc", + }, + { + desc: "heuristic trims different prefixes from relative path", + sourcePath: "$dir", + fs: []string{"bar.cc"}, + path: "github.com/x/foo/bar.cc", + wantPath: "$dir/bar.cc", + }, + { + desc: "heuristic trims different prefixes from relative path not found", + sourcePath: "$dir", + fs: []string{"baz.cc"}, + path: "github.com/x/foo/bar.cc", + wantPath: "github.com/x/foo/bar.cc", + }, + { + desc: "heuristic trims different prefixes from absolute path", + sourcePath: "$dir", + fs: []string{"foo/bar.cc"}, + path: "/x/foo/bar.cc", + wantPath: "$dir/foo/bar.cc", + }, + { + desc: "heuristic trims different prefixes from absolute path not found", + sourcePath: "$dir", + fs: []string{"foo/baz.cc"}, + path: "/x/foo/bar.cc", + wantPath: "/x/foo/bar.cc", + }, + { + desc: "heuristic trims same directory", + sourcePath: "$dir/my-project", fs: []string{"my-project/foo/bar.cc"}, path: "/some/remote/path/my-project/foo/bar.cc", wantPath: "$dir/my-project/foo/bar.cc", }, { - desc: "error when not found", - path: "foo.cc", + desc: "heuristic trims same directory not found", + sourcePath: "$dir/my-project", + fs: []string{"my-project/foo/baz.cc"}, + path: "/some/remote/path/my-project/foo/bar.cc", + wantPath: "/some/remote/path/my-project/foo/bar.cc", + }, + { + desc: "heuristic trims different directory", + sourcePath: "$dir/my-local-project", + fs: []string{"my-local-project/foo/bar.cc"}, + path: "/some/remote/path/my-project/foo/bar.cc", + wantPath: "$dir/my-local-project/foo/bar.cc", + }, + { + desc: "heuristic trims different directory not found", + sourcePath: "$dir/my-local-project", + fs: []string{"my-local-project/foo/baz.cc"}, + path: "/some/remote/path/my-project/foo/bar.cc", + wantPath: "/some/remote/path/my-project/foo/bar.cc", }, } { t.Run(tc.desc, func(t *testing.T) { @@ -197,19 +284,16 @@ func TestOpenSourceFile(t *testing.T) { t.Fatalf("failed to create file %q: %v", path, err) } } - tc.searchPath = filepath.FromSlash(strings.Replace(tc.searchPath, "$dir", tempdir, -1)) + tc.sourcePath = filepath.FromSlash(strings.Replace(tc.sourcePath, "$dir", tempdir, -1)) tc.path = filepath.FromSlash(strings.Replace(tc.path, "$dir", tempdir, 1)) tc.wantPath = filepath.FromSlash(strings.Replace(tc.wantPath, "$dir", tempdir, 1)) - if file, err := openSourceFile(tc.path, tc.searchPath, tc.trimPath); err != nil && tc.wantPath != "" { - t.Errorf("openSourceFile(%q, %q, %q) = err %v, want path %q", tc.path, tc.searchPath, tc.trimPath, err, tc.wantPath) - } else if err == nil { - defer file.Close() - gotPath := file.Name() - if tc.wantPath == "" { - t.Errorf("openSourceFile(%q, %q, %q) = %q, want error", tc.path, tc.searchPath, tc.trimPath, gotPath) - } else if gotPath != tc.wantPath { - t.Errorf("openSourceFile(%q, %q, %q) = %q, want path %q", tc.path, tc.searchPath, tc.trimPath, gotPath, tc.wantPath) - } + + sourcePaths := filepath.SplitList(tc.sourcePath) + trimPaths := filepath.SplitList(tc.trimPath) + + filename := sourceFilename(tc.path, sourcePaths, trimPaths) + if filename != tc.wantPath { + t.Errorf("sourceFilename(%q, %q, %q) = %q, want %q", tc.path, tc.sourcePath, tc.trimPath, filename, tc.wantPath) } }) } diff --git a/internal/report/stacks.go b/internal/report/stacks.go index dbf20bebe9..67e69f625e 100644 --- a/internal/report/stacks.go +++ b/internal/report/stacks.go @@ -127,7 +127,7 @@ func (s *StackSet) makeInitialStacks(rpt *Report) { return i } - fileName := trimPath(fn.Filename, rpt.options.TrimPath, rpt.options.SourcePath) + fileName := sourceFilename(fn.Filename, rpt.options.sourcePaths(), rpt.options.trimPaths()) x := StackSource{ FileName: fileName, Inlined: inlined,