diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e697421 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + cache: false + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.54 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b52c72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +heapdump* +heapview* +dist/* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3aae906 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# heapview + +A tiny, experimental heap dump viewer for Go heap dumps. (for heap dumps produced by `debug.WriteHeapDump()`) + +Tested on Go 1.21.0. + +## Installation + +The easiest way to get started is to install `heapview` by downloading the [releases](https://github.com/burntcarrot/heapview/releases). + +## Usage + +```sh +heapview -file= +``` + +On running `heapview`, the server would serve the HTML view at `localhost:8080`: + +![Records View](./static/records-view.png) + +Graph view: + +![Graph View](./static/graph-view.png) + +## Future work + +`heapview` is a small tool, but can be improved with the following features: + +- a good, responsive Object Graph viewer, which could redirect to the record on interactions with the nodes +- a way to extract type information from the heap dumps +- an easier way to be in sync with the Go runtime changes + +If you'd like to contribute to the following, please consider raising a pull request! + +## Acknowledgements + +- https://github.com/golang/go/wiki/heapdump15-through-heapdump17, which documents the current Go heap dump format. (and was the main reference while I was building [heaputil](https://github.com/burntcarrot/heaputil)) +- https://github.com/golang/go/issues/16410, the Go heap dump viewer proposal +- https://github.com/adamroach/heapspurs, which aims to provide a set of utilities to play around with the Go heap dump. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d87aec5 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/burntcarrot/heapview + +go 1.21.0 + +require github.com/burntcarrot/heaputil v0.0.0-20230927162808-497024fb706a diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..36be9da --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/burntcarrot/heaputil v0.0.0-20230927162808-497024fb706a h1:w1S7K4+qL19+czjhyWrgyc8QmaseY1f3mkP4YPcAdFM= +github.com/burntcarrot/heaputil v0.0.0-20230927162808-497024fb706a/go.mod h1:LwbcObA3AsK5SN1Bet7dQjOkxuKqJ/B0iM0Qn6VdxPk= diff --git a/goreleaser.yml b/goreleaser.yml new file mode 100644 index 0000000..b5575d6 --- /dev/null +++ b/goreleaser.yml @@ -0,0 +1,16 @@ +project_name: heapview + +builds: + - id: "heapview" + binary: heapview + goos: + - linux + - darwin + - windows + - openbsd + goarch: + - amd64 + - arm64 + mod_timestamp: '{{ .CommitTimestamp }}' + env: + - CGO_ENABLED=0 \ No newline at end of file diff --git a/html.go b/html.go new file mode 100644 index 0000000..905cd73 --- /dev/null +++ b/html.go @@ -0,0 +1,132 @@ +package main + +import ( + "bufio" + "fmt" + "html/template" + "regexp" + "strings" + + "github.com/burntcarrot/heaputil" + "github.com/burntcarrot/heaputil/record" +) + +type templateData struct { + RecordTypes []RecordInfo + Records []heaputil.RecordData + GraphVizContent string +} + +func GenerateHTML(records []heaputil.RecordData, graphContent string) (string, error) { + tmpl, err := template.ParseFiles("index.html") + if err != nil { + return "", err + } + + data := templateData{ + RecordTypes: GetUniqueRecordTypes(records), + Records: records, + GraphVizContent: graphContent, + } + + var htmlBuilder strings.Builder + err = tmpl.Execute(&htmlBuilder, data) + if err != nil { + return "", err + } + + return htmlBuilder.String(), nil +} + +func GenerateGraph(rd *bufio.Reader) (string, error) { + err := record.ReadHeader(rd) + if err != nil { + return "", err + } + + var dotContent strings.Builder + + // Write DOT file header + dotContent.WriteString("digraph GoHeapDump {\n") + + // Create the "heap" node as a cluster + dotContent.WriteString(" subgraph cluster_heap {\n") + dotContent.WriteString(" label=\"Heap\";\n") + dotContent.WriteString(" style=dotted;\n") + + var dumpParams *record.DumpParamsRecord + counter := 0 + + for { + r, err := record.ReadRecord(rd) + if err != nil { + return dotContent.String(), err + } + + _, isEOF := r.(*record.EOFRecord) + if isEOF { + break + } + + dp, isDumpParams := r.(*record.DumpParamsRecord) + if isDumpParams { + dumpParams = dp + } + + // Filter out objects. If the record isn't of the type Object, ignore. + _, isObj := r.(*record.ObjectRecord) + if !isObj { + continue + } + + // Create a DOT node for each record + nodeName := fmt.Sprintf("Node%d", counter) + counter++ + name, address := ParseNameAndAddress(r.Repr()) + nodeLabel := fmt.Sprintf("[%s] %s", name, address) + + // Write DOT node entry within the "heap" cluster + s := fmt.Sprintf(" %s [label=\"%s\"];\n", nodeName, nodeLabel) + dotContent.WriteString(s) + + // Check if the record has pointers + p, isParent := r.(record.ParentGuard) + if isParent { + _, outgoing := record.ParsePointers(p, dumpParams) + for i := 0; i < len(outgoing); i++ { + if outgoing[i] != 0 { + childNodeName := fmt.Sprintf("Pointer0x%x", outgoing[i]) + + // Create an edge from the current record to the child record + s := fmt.Sprintf(" %s -> %s;\n", nodeName, childNodeName) + dotContent.WriteString(s) + } + } + } + } + + // Close the "heap" cluster + dotContent.WriteString(" }\n") + + // Write DOT file footer + dotContent.WriteString("}\n") + + return dotContent.String(), nil +} + +func ParseNameAndAddress(input string) (name, address string) { + // Define a regular expression pattern to match the desired format + // The pattern captures the node name (before " at address") and the address. + re := regexp.MustCompile(`^(.*?) at address (0x[0-9a-fA-F]+).*?$`) + + // Find the submatches in the input string + matches := re.FindStringSubmatch(input) + + // If there are no matches, return empty strings for both name and address + if len(matches) != 3 { + return "", "" + } + + // The first submatch (matches[1]) contains the node name, and the second submatch (matches[2]) contains the address. + return matches[1], matches[2] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..b6ebab7 --- /dev/null +++ b/index.html @@ -0,0 +1,245 @@ + + + + HeapView + + + +
+

HeapView

+

a heap dump viewer for Go heap dumps

+
+ +
+ + +
+
+ +
+ {{range .Records}} +
+ {{.Repr}} + {{if .HasPointers}} + + + {{end}} +
+ {{end}} +
+ + + +
+ + + + + + \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..64227aa --- /dev/null +++ b/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + + "github.com/burntcarrot/heaputil" +) + +func main() { + filePath := flag.String("file", "", "Path to the heap dump file") + flag.Parse() + + if *filePath == "" { + log.Fatal("Please provide the path to the heap dump file using the -file flag.") + } + + file, err := os.Open(*filePath) + if err != nil { + log.Fatalf("Failed to open file: %v", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + + graphContent, err := GenerateGraph(reader) + if err != nil { + log.Fatalf("Failed to generate graph: %v", err) + } + + _, err = file.Seek(0, 0) + if err != nil { + log.Fatalf("Failed to seek to starting point: %v", err) + } + reader.Reset(file) + + records, err := heaputil.ParseDump(reader) + if err != nil { + log.Fatalf("Failed to parse records: %v", err) + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + html, err := GenerateHTML(records, graphContent) + if err != nil { + http.Error(w, fmt.Sprintf("Error generating HTML: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html") + + _, err = io.WriteString(w, html) + if err != nil { + http.Error(w, fmt.Sprintf("Error writing response: %v", err), http.StatusInternalServerError) + } + }) + + port := ":8080" + log.Printf("Server is running on port %s\n", port) + log.Fatal(http.ListenAndServe(port, nil)) +} diff --git a/static/graph-view.png b/static/graph-view.png new file mode 100755 index 0000000..1c89400 Binary files /dev/null and b/static/graph-view.png differ diff --git a/static/records-view.png b/static/records-view.png new file mode 100755 index 0000000..1544717 Binary files /dev/null and b/static/records-view.png differ diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..8bc0050 --- /dev/null +++ b/utils.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/burntcarrot/heaputil" + "github.com/burntcarrot/heaputil/record" +) + +type RecordInfo struct { + RecordType record.RecordType + RecordTypeStr string +} + +func GetUniqueRecordTypes(records []heaputil.RecordData) []RecordInfo { + recordTypesMap := map[record.RecordType]bool{} + for _, recordInfo := range records { + recordTypesMap[recordInfo.RecordType] = true + } + + recordTypes := []RecordInfo{} + for rType := range recordTypesMap { + recordTypes = append(recordTypes, RecordInfo{RecordType: rType, RecordTypeStr: record.GetStrFromRecordType(rType)}) + } + + return recordTypes +}