├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── assign.go ├── conf.go ├── expr.go ├── go.mod ├── go.sum ├── goroutine.go ├── goroutine_test.go ├── load.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Build output 14 | goroutine-inspect 15 | 16 | # Testing 17 | testdata/ 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, linuxerwang 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += --warn-undefined-variables 2 | SHELL := /bin/bash 3 | .SHELLFLAGS := -o pipefail -euc 4 | .DEFAULT_GOAL := build 5 | 6 | GO_SRC := $(wildcard ./*.go) 7 | 8 | .PHONY: build 9 | build: dist/goroutine-inspect 10 | 11 | dist/goroutine-inspect: $(GO_SRC) 12 | @mkdir -p ./dist 13 | go build -trimpath -o dist/goroutine-inspect . 14 | 15 | .PHONY: test 16 | test: 17 | go test -v -count=1 ./... 18 | 19 | .PHONY: check 20 | check: 21 | go vet ./... 22 | golangci-lint run ./... 23 | go mod tidy 24 | 25 | .PHONY: clean 26 | clean: 27 | rm -rf ./dist 28 | 29 | .PHONY: install 30 | install: dist/goroutine-inspect 31 | ln -sf $(shell pwd)/dist/goroutine-inspect ~/go/bin/goroutine-inspect 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goroutine-inspect 2 | 3 | An interactive tool to analyze Golang goroutine dump. 4 | 5 | _Note: this is my personal fork of [linuxerwang/goroutine-inspect] and 6 | has no support or guarantee that breaking behavior changes won't 7 | change at any time._ 8 | 9 | ## Build and Run 10 | 11 | Upstream does not use go modules, so the package has been renamed in 12 | `go.mod` so that installation with current toolchains works as 13 | expected. Install this fork with: 14 | 15 | ```bash 16 | go install github.com/tgross/goroutine-inspect@latest 17 | ``` 18 | 19 | Or build and run from source with: 20 | 21 | ```bash 22 | go build . 23 | ./goroutine-inspect 24 | ``` 25 | 26 | ## Workspace 27 | 28 | Workspace is the place to hold imported goroutine dumps. Instructions are 29 | provided to maintain these dumps. 30 | 31 | In the interactive shell, two kinds of instructions can be issued: commands 32 | and statements. Shell history is stored in the user's `XDG_CACHE_HOME` 33 | directory. 34 | 35 | ## Commands 36 | 37 | At present, the following commands are supported. 38 | 39 | | command | function | 40 | | ------- | --------------------------------- | 41 | | cd | Change current working directory. | 42 | | clear | Clear the workspace. | 43 | | exit | Exit the interactive shell. | 44 | | help | Show help. | 45 | | ls | Show files in current directory. | 46 | | pwd | Show present working directory. | 47 | | quit | Quit the interactive shell. | 48 | | whos | Show all varaibles in workspace. | 49 | 50 | ## Statements 51 | 52 | ### Load Goroutine Dump From Files 53 | 54 | Load the dump and assign to a variable: 55 | 56 | ```bash 57 | >> original = load("pprof-goroutines-20170510-170245.dump") 58 | >> whos 59 | original 60 | ``` 61 | 62 | ### Show the Summary of a Dump Var 63 | 64 | Simply type the variable name: 65 | 66 | ```bash 67 | >> original 68 | # of goroutines: 2217 69 | 70 | running: 1 71 | IO wait: 533 72 | syscall: 2 73 | chan receive: 50 74 | select: 1504 75 | runnable: 38 76 | semacquire: 85 77 | chan send: 4 78 | 79 | ``` 80 | 81 | ### Copy a Dump Var 82 | 83 | To copy the whole dump, simply assign it to a different var: 84 | 85 | ```bash 86 | >> copy1 = original 87 | >> whos 88 | copy original 89 | ``` 90 | 91 | It's equivalent to using a copy() function: 92 | 93 | ```bash 94 | >> copy2 = original.copy() 95 | >> whos 96 | copy copy1 copy2 original 97 | ``` 98 | 99 | The copy() function allows passing a conditional so that only those meeting 100 | the cariteria will be copied: 101 | 102 | ```bash 103 | >> copy3 = original.copy("id>900 && id<2000") 104 | ``` 105 | 106 | ### Modify the Dump Goroutine Items 107 | 108 | Function delete() accepts a conditional to delete goroutine items in a dump 109 | var. Function keep() do the reversed conditional. 110 | 111 | ```bash 112 | >> copy 113 | # of goroutines: 2217 114 | 115 | running: 1 116 | IO wait: 533 117 | syscall: 2 118 | chan receive: 50 119 | select: 1504 120 | runnable: 38 121 | semacquire: 85 122 | chan send: 4 123 | 124 | >> copy.delete("id>100 && id<1000") 125 | Filtered 118 goroutines, kept 2099. 126 | >> copy.keep("id>200") 127 | Filtered 12 goroutines, kept 2087. 128 | >> copy 129 | # of goroutines: 2087 130 | 131 | running: 1 132 | select: 1411 133 | IO wait: 500 134 | semacquire: 85 135 | runnable: 37 136 | chan receive: 49 137 | chan send: 4 138 | 139 | ``` 140 | 141 | ### Display Goroutine Dump Items 142 | 143 | Function show() displays goroutine dump items with optional offset and limit. 144 | The default offset is 0, and default limit is 10. 145 | 146 | ```bash 147 | >> original.show() # offset 0, limit 10 148 | 149 | goroutine 1803 [select, 10 minutes]: 150 | google.golang.org/grpc/transport.(*http2Server).keepalive(0xc420e59ce0) 151 | google.golang.org/grpc/transport/http2_server.go:919 +0x488 152 | created by google.golang.org/grpc/transport.newHTTP2Server 153 | google.golang.org/grpc/transport/http2_server.go:226 +0x97c 154 | 155 | ... 156 | ... 157 | 158 | >> original.show(15) # offset 15, limit 10 159 | 160 | goroutine 6455709 [running]: 161 | runtime/pprof.writeGoroutineStacks(0xe9a080, 0xc4216f0088, 0x1d, 0x40) 162 | go1.8.1.linux-amd64/src/runtime/pprof/pprof.go:603 +0x79 163 | runtime/pprof.writeGoroutine(0xe9a080, 0xc4216f0088, 0x2, 0x1d, 0xc4217cede0) 164 | go1.8.1.linux-amd64/src/runtime/pprof/pprof.go:592 +0x44 165 | runtime/pprof.(*Profile).WriteTo(0xed3780, 0xe9a080, 0xc4216f0088, 0x2, 0xc4217cef80, 0x1) 166 | go1.8.1.linux-amd64/src/runtime/pprof/pprof.go:302 +0x3b5 167 | www.test.com/bagel/runtime.dumpToFile(0xed0f0ba5e, 0xae05027, 0xee1780, 0xc425bd2060, 0x5, 0x5) 168 | www.test.com/bagel/runtime/dump.go:58 +0x3f3 169 | created by www.test.com/bagel/runtime.EnableGoroutineDump.func1 170 | www.test.com/bagel/runtime/dump.go:30 +0x2d6 171 | 172 | ... 173 | ... 174 | 175 | >> original.show(15, 1) # offset 15, limit 1 176 | 177 | goroutine 6455709 [running]: 178 | runtime/pprof.writeGoroutineStacks(0xe9a080, 0xc4216f0088, 0x1d, 0x40) 179 | go1.8.1.linux-amd64/src/runtime/pprof/pprof.go:603 +0x79 180 | runtime/pprof.writeGoroutine(0xe9a080, 0xc4216f0088, 0x2, 0x1d, 0xc4217cede0) 181 | go1.8.1.linux-amd64/src/runtime/pprof/pprof.go:592 +0x44 182 | runtime/pprof.(*Profile).WriteTo(0xed3780, 0xe9a080, 0xc4216f0088, 0x2, 0xc4217cef80, 0x1) 183 | go1.8.1.linux-amd64/src/runtime/pprof/pprof.go:302 +0x3b5 184 | www.test.com/bagel/runtime.dumpToFile(0xed0f0ba5e, 0xae05027, 0xee1780, 0xc425bd2060, 0x5, 0x5) 185 | www.test.com/bagel/runtime/dump.go:58 +0x3f3 186 | created by www.test.com/bagel/runtime.EnableGoroutineDump.func1 187 | www.test.com/bagel/runtime/dump.go:30 +0x2d6 188 | ``` 189 | 190 | ### Search Goroutine Dump Items 191 | 192 | Similar to show(), but with a conditional to only show items meeting certain 193 | criteria. The offset and limit arguments for search() are applied only to 194 | matching goroutines. 195 | 196 | ```bash 197 | >> original.search("id < 2000", 15, 1) # offset 15, limit 1 198 | 199 | goroutine 6455896 [select]: 200 | net.(*netFD).connect.func2(0xea1980, 0xc424bca540, 0xc422c1af50, 0xc424bca600, 0xc424bca5a0) 201 | go1.8.1.linux-amd64/src/net/fd_unix.go:133 +0x1d5 202 | created by net.(*netFD).connect 203 | go1.8.1.linux-amd64/src/net/fd_unix.go:144 +0x239 204 | ``` 205 | 206 | One useful ability is to filter goroutines by running time: 207 | 208 | ```bash 209 | >> original.search("duration > 10") # duration larger than 10 minutes 210 | 211 | goroutine 72 [select, 25 minutes]: 119 times: [72, 54755, 76757, 299, 201, 286, 283, 296, 204, 302, 207, 305, 338, 356, 359, 362, 365, 372, 375, 368, 378, 328, 331, 387, 381 212 | , 390, 384, 403, 393, 334, 406, 396, 399, 337, 418, 341, 436, 344, 439, 421, 424, 409, 427, 452, 430, 433, 442, 455, 445, 458, 448, 461, 464, 468, 483, 471, 499, 486, 502, 5 213 | 05, 489, 76462, 76773, 54530, 54572, 55194, 54824, 54481, 42719, 54691, 54859, 55023, 75593, 76750, 55202, 54885, 79006, 54468, 55212, 54473, 54462, 54931, 54864, 55133, 550 214 | 97, 54882, 54901, 55209, 54499, 55114, 54564, 76653, 54416, 54527, 75588, 55034, 54868, 54791, 54813, 54698, 54579, 55111, 54443, 54486, 76467, 54654, 54537, 54456, 55126, 5 215 | 5117, 54622, 55199, 54556, 54477, 54871, 79498, 76601, 76735, 76996] 216 | google.golang.org/grpc/transport.(*http2Server).keepalive(0xc4202f0420) 217 | google.golang.org/grpc/transport/http2_server.go:919 +0x488 218 | created by google.golang.org/grpc/transport.newHTTP2Server 219 | google.golang.org/grpc/transport/http2_server.go:226 +0x97c 220 | ``` 221 | 222 | Note that the above is after a dedup operation, so it shows the same stack trace 223 | existing 119 times. See the "Dedup goroutines" section. 224 | 225 | Search expressions can combine terms using the syntax described by the 226 | underlying [`govaluate`] library: 227 | 228 | ```bash 229 | >> original.search("state == 'select' && (duration > 10 || duration <= 1)") 230 | ``` 231 | 232 | ### Diff Two Goroutine Dumps 233 | 234 | ```bash 235 | >> l, c, r = x.diff(y) 236 | >> l 237 | # of goroutines: 574 238 | 239 | IO wait: 147 240 | chan receive: 1 241 | runnable: 3 242 | select: 421 243 | syscall: 2 244 | 245 | >> c 246 | # of goroutines: 651 247 | 248 | IO wait: 157 249 | runnable: 4 250 | select: 489 251 | semacquire: 1 252 | 253 | >> r 254 | # of goroutines: 992 255 | 256 | IO wait: 229 257 | chan receive: 49 258 | chan send: 4 259 | runnable: 31 260 | running: 1 261 | select: 594 262 | semacquire: 84 263 | ``` 264 | 265 | It returns three values: the dump var containing goroutines only appear in 266 | x (the left side), the dump var containing goroutines appear in both x and y, 267 | the dump var containing goroutines only appear in y (the right side). 268 | 269 | ### Dedup goroutines 270 | 271 | Normally goroutine dump files contain thousands of goroutine entries, but 272 | there are many duplicated traces. Function dedup() helps to identify these 273 | duplicated traces by comparing the trace lines, and only keep one copy of 274 | them. It greatly reduces the information explosion and make developers much 275 | easier to focus on their problems. 276 | 277 | ```bash 278 | >> a 279 | # of goroutines: 2217 280 | 281 | IO wait: 533 282 | chan receive: 50 283 | chan send: 4 284 | runnable: 38 285 | running: 1 286 | select: 1504 287 | semacquire: 85 288 | syscall: 2 289 | 290 | >> a.dedup() 291 | Dedupped 2217, kept 46 292 | >> 293 | >> a 294 | # of goroutines: 46 295 | 296 | IO wait: 6 297 | chan receive: 2 298 | chan send: 2 299 | runnable: 18 300 | running: 1 301 | select: 12 302 | semacquire: 3 303 | syscall: 2 304 | ``` 305 | 306 | To show goroutines with 5+ duplicates: 307 | 308 | ```bash 309 | >> a.search("dups >= 5") 310 | ... 311 | ``` 312 | 313 | ### Save the Modified Goroutine Dump to a File 314 | 315 | After a dump var is modified, it can be saved to a file: 316 | 317 | ```bash 318 | >> a.save("pprof-deduped.log") 319 | ``` 320 | 321 | ## Properties of a Goroutine Dump Item 322 | 323 | Each dump item has 5 properties which can be used in conditionals: 324 | 325 | | property | type | meaning | 326 | | -------- | ------- | --------------------------------------------------- | 327 | | id | integer | The goroutine ID. | 328 | | dups | integer | The number of duplicate traces. | 329 | | duration | integer | The waiting duration (in minutes) of a goroutine. | 330 | | lines | integer | The number of lines of the goroutine's stack trace. | 331 | | state | string | The running state of the goroutine. | 332 | | trace | string | The concatenated text of the goroutine stack trace. | 333 | 334 | ## Functions in Conditionals 335 | 336 | The following functions can be used in defining conditionals: 337 | 338 | | function | args | return value | meaning | 339 | | -------- | -------------- | ------------ | ----------------------------------------------------- | 340 | | contains | string, string | bool | Returns true if the first arg contains the second arg | 341 | | lower | string | string | Returns the lowercased string of the input. | 342 | | upper | string | string | Returns the uppercased string of the input. | 343 | 344 | Example: 345 | 346 | ```bash 347 | >> original.search("contains(lower(trace), 'handlestream')") 348 | ``` 349 | 350 | 351 | [`govaluate`]: https://github.com/Knetic/govaluate#what-operators-and-types-does-this-support 352 | [linuxerwang/goroutine-inspect]: https://github.com/linuxerwang/goroutine-inspect 353 | -------------------------------------------------------------------------------- /assign.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "go/ast" 7 | "go/parser" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | identifierPattern = regexp.MustCompile(`[_a-zA-Z][_a-zA-Z0-9]*`) 14 | identifiersPattern = regexp.MustCompile(`[_a-zA-Z][_a-zA-Z0-9]*(\s*,\s*[_a-zA-Z][_a-zA-Z0-9]*)*\s*`) 15 | ) 16 | 17 | func assign(cmd string) error { 18 | if idx := strings.Index(cmd, "="); idx > 0 { 19 | k := strings.TrimSpace(cmd[:idx]) 20 | if k == "" { 21 | return errors.New("incomplete assignment") 22 | } 23 | if !identifiersPattern.MatchString(k) { 24 | if !identifierPattern.MatchString(k) { 25 | return fmt.Errorf("invalid variable name %s", k) 26 | } 27 | } 28 | 29 | v := strings.TrimSpace(cmd[idx+1:]) 30 | if v == "" { 31 | return errors.New("incomplete assignment") 32 | } 33 | 34 | ex, err := parser.ParseExpr(v) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | switch ex := ex.(type) { 40 | case *ast.CallExpr: 41 | switch fun := ex.Fun.(type) { 42 | case *ast.SelectorExpr: 43 | s := fun.X.(*ast.Ident).Name 44 | if val, ok := workspace[s]; ok { 45 | switch fun.Sel.Name { 46 | case "copy": 47 | if len(ex.Args) > 1 { 48 | return errors.New("copy expects zero or one argument") 49 | } 50 | if len(ex.Args) == 0 { 51 | workspace[k] = val.Copy("") 52 | } else { 53 | workspace[k] = val.Copy(ex.Args[0].(*ast.BasicLit).Value) 54 | } 55 | case "diff": 56 | if len(ex.Args) != 1 { 57 | return errors.New("diff() expects exactly one argument") 58 | } 59 | args := strings.Split(k, ",") 60 | if len(args) == 0 || len(args) > 3 { 61 | return errors.New("diff() expects at least one and at most 3 result receiver") 62 | } 63 | varName := strings.TrimSpace(ex.Args[0].(*ast.Ident).Name) 64 | if val, ok := workspace[varName]; ok { 65 | if v, ok := workspace[s]; ok { 66 | lonly, common, ronly := v.Diff(val) 67 | if len(args) >= 1 { 68 | workspace[strings.TrimSpace(args[0])] = lonly 69 | } 70 | if len(args) >= 2 { 71 | workspace[strings.TrimSpace(args[1])] = common 72 | } 73 | if len(args) == 3 { 74 | workspace[strings.TrimSpace(args[2])] = ronly 75 | } 76 | } else { 77 | return fmt.Errorf("variable %s not found in workspace", s) 78 | } 79 | } else { 80 | return fmt.Errorf("variable %s not found in workspace", varName) 81 | } 82 | return nil 83 | default: 84 | return fmt.Errorf("%s.%s() is not allowed for assigning to a variable", k, fun.Sel.Name) 85 | } 86 | } else { 87 | return fmt.Errorf("variable %s not found in workspace", s) 88 | } 89 | case *ast.Ident: 90 | if fun.Name == "load" { 91 | if len(ex.Args) != 1 { 92 | return errors.New("load() expects exactly one argument") 93 | } 94 | lit, err := literal(ex.Args[0]) 95 | if err != nil { 96 | return fmt.Errorf("invalid input to load(): %w", err) 97 | } 98 | dump, err := load(lit) 99 | if err != nil { 100 | return err 101 | } 102 | workspace[k] = dump 103 | dump.Summary() 104 | } else { 105 | return fmt.Errorf("unknown instruction %s", fun.Name) 106 | } 107 | default: 108 | return fmt.Errorf("unknown instruction") 109 | } 110 | case *ast.Ident: 111 | if v, ok := workspace[ex.String()]; ok { 112 | workspace[k] = v.Copy("") 113 | } else { 114 | return fmt.Errorf("variable %s not found in workspace", ex.String()) 115 | } 116 | default: 117 | return errors.New("unknown instruction") 118 | } 119 | } 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /conf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/peterh/liner" 10 | ) 11 | 12 | func getConfDir() string { 13 | userDir, err := os.UserCacheDir() 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | 18 | dir := filepath.Join(userDir, "goroutine-inspect") 19 | if _, err := os.Stat(dir); os.IsNotExist(err) { 20 | if err = os.MkdirAll(dir, os.ModePerm); err != nil { 21 | log.Fatal(err) 22 | } 23 | } 24 | 25 | return dir 26 | } 27 | 28 | func getHistoryFile() string { 29 | return filepath.Join(getConfDir(), "history") 30 | } 31 | 32 | func createLiner() (*liner.State, error) { 33 | line := liner.NewLiner() 34 | line.SetCompleter(func(line string) (c []string) { 35 | for n := range commands { 36 | if strings.HasPrefix(n, strings.ToLower(line)) { 37 | c = append(c, n) 38 | } 39 | } 40 | return 41 | }) 42 | 43 | if f, err := os.Open(getHistoryFile()); err == nil { 44 | defer f.Close() 45 | _, err := line.ReadHistory(f) 46 | if err != nil { 47 | return nil, err 48 | } 49 | } 50 | 51 | return line, nil 52 | } 53 | 54 | func saveLiner(liner *liner.State) error { 55 | f, err := os.Create(getHistoryFile()) 56 | if err != nil { 57 | log.Fatal("Error writing history file: ", err) 58 | } 59 | defer f.Close() 60 | 61 | _, err = liner.WriteHistory(f) 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /expr.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "go/ast" 7 | "go/parser" 8 | "os" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | var errExpectedLiteral = errors.New("expected basic literal (ex. string or number)") 14 | 15 | func literal(ex ast.Expr) (string, error) { 16 | lit, ok := ex.(*ast.BasicLit) 17 | if !ok { 18 | return "", errExpectedLiteral 19 | } 20 | 21 | return lit.Value, nil 22 | } 23 | 24 | func expr(e string) error { 25 | ex, err := parser.ParseExpr(e) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | switch ex := ex.(type) { 31 | case *ast.CallExpr: 32 | switch fun := ex.Fun.(type) { 33 | case *ast.SelectorExpr: 34 | ident, ok := fun.X.(*ast.Ident) 35 | if !ok { 36 | return errors.New("expected identifier") 37 | } 38 | k := ident.Name 39 | if v, ok := workspace[k]; ok { 40 | switch fun.Sel.Name { 41 | case "delete": 42 | if len(ex.Args) != 1 { 43 | return errors.New("delete() expects exactly one argument") 44 | } 45 | lit, err := literal(ex.Args[0]) 46 | if err != nil { 47 | return fmt.Errorf("invalid input to delete(): %w", err) 48 | } 49 | return v.Delete(lit) 50 | case "dedup": 51 | if len(ex.Args) != 0 { 52 | return errors.New("dedup() expects no arguments") 53 | } 54 | v.Dedup() 55 | return nil 56 | case "keep": 57 | if len(ex.Args) != 1 { 58 | return errors.New("delete() expects exactly one argument") 59 | } 60 | lit, err := literal(ex.Args[0]) 61 | if err != nil { 62 | return fmt.Errorf("invalid input to keep(): %w", err) 63 | } 64 | return v.Keep(lit) 65 | case "save": 66 | if len(ex.Args) != 1 { 67 | return errors.New("save() expects exactly one argument") 68 | } 69 | lit, err := literal(ex.Args[0]) 70 | if err != nil { 71 | return fmt.Errorf("invalid input to save(): %w", err) 72 | } 73 | fn := strings.Trim(lit, "\"") 74 | if _, err := os.Stat(fn); err == nil { 75 | pmpt := fmt.Sprintf("File %s already exists, overwrite it? [Y]/n: ", fn) 76 | var confirm string 77 | if confirm, err = line.Prompt(pmpt); err != nil { 78 | return err 79 | } 80 | confirm = strings.ToLower(strings.TrimSpace(confirm)) 81 | if confirm != "y" && confirm != "" { 82 | return nil 83 | } 84 | } 85 | if err := v.Save(fn); err != nil { 86 | return err 87 | } 88 | case "search": 89 | var err error 90 | offset := 0 91 | limit := 10 92 | switch len(ex.Args) { 93 | case 0: 94 | return errors.New("search() expects at least one argument") 95 | case 1: 96 | case 2: 97 | offset, err = strconv.Atoi(ex.Args[1].(*ast.BasicLit).Value) 98 | if err != nil { 99 | return fmt.Errorf("invalid argument %s", ex.Args[1]) 100 | } 101 | case 3: 102 | offset, err = strconv.Atoi(ex.Args[1].(*ast.BasicLit).Value) 103 | if err != nil { 104 | return fmt.Errorf("invalid argument 'offset' %s", ex.Args[1]) 105 | } 106 | limit, err = strconv.Atoi(ex.Args[2].(*ast.BasicLit).Value) 107 | if err != nil { 108 | return fmt.Errorf("invalid argument 'limit' %s", ex.Args[2]) 109 | } 110 | default: 111 | return errors.New("search() expects at most three arguments") 112 | } 113 | lit, ok := ex.Args[0].(*ast.BasicLit) 114 | if !ok { 115 | return errors.New("search() expects a quoted expression") 116 | } 117 | v.Search(lit.Value, offset, limit) 118 | return nil 119 | case "show": 120 | var err error 121 | offset := 0 122 | limit := 10 123 | switch len(ex.Args) { 124 | case 0: 125 | case 1: 126 | offset, err = strconv.Atoi(ex.Args[0].(*ast.BasicLit).Value) 127 | if err != nil { 128 | return fmt.Errorf("invalid argument %s", ex.Args[0]) 129 | } 130 | case 2: 131 | offset, err = strconv.Atoi(ex.Args[0].(*ast.BasicLit).Value) 132 | if err != nil { 133 | return fmt.Errorf("invalid argument 'offset' %s", ex.Args[0]) 134 | } 135 | limit, err = strconv.Atoi(ex.Args[1].(*ast.BasicLit).Value) 136 | if err != nil { 137 | return fmt.Errorf("invalid argument 'limit' %s", ex.Args[1]) 138 | } 139 | default: 140 | return errors.New("show() expects at least one and at most two arguments") 141 | } 142 | v.Show(offset, limit) 143 | return nil 144 | default: 145 | return fmt.Errorf("unknown instruction") 146 | } 147 | } 148 | default: 149 | return fmt.Errorf("unknown instruction") 150 | } 151 | case *ast.Ident: 152 | if v, ok := workspace[ex.String()]; ok { 153 | v.Summary() 154 | } else { 155 | return fmt.Errorf("variable %s not found in workspace", e) 156 | } 157 | default: 158 | return fmt.Errorf("unknown instruction") 159 | } 160 | 161 | return nil 162 | } 163 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tgross/goroutine-inspect 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/Knetic/govaluate v3.0.0+incompatible 7 | github.com/mattn/go-colorable v0.1.13 8 | github.com/peterh/liner v1.2.1 9 | github.com/shoenig/test v1.7.2 10 | ) 11 | 12 | require ( 13 | github.com/google/go-cmp v0.6.0 // indirect 14 | github.com/mattn/go-isatty v0.0.16 // indirect 15 | github.com/mattn/go-runewidth v0.0.3 // indirect 16 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Knetic/govaluate v3.0.0+incompatible h1:7o6+MAPhYTCF0+fdvoz1xDedhRb4f6s9Tn1Tt7/WTEg= 2 | github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= 3 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 4 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 6 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 7 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 8 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 9 | github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= 10 | github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 11 | github.com/peterh/liner v1.2.1 h1:O4BlKaq/LWu6VRWmol4ByWfzx6MfXc5Op5HETyIy5yg= 12 | github.com/peterh/liner v1.2.1/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= 13 | github.com/shoenig/test v1.7.2 h1:r/HCsyOMT3BfRIBs14fgc8M9gVZx8KvJom1a9qMDzcw= 14 | github.com/shoenig/test v1.7.2/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= 15 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 16 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 17 | -------------------------------------------------------------------------------- /goroutine.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "errors" 7 | "fmt" 8 | "hash" 9 | "io" 10 | "os" 11 | "regexp" 12 | "sort" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/Knetic/govaluate" 17 | ) 18 | 19 | type MetaType int 20 | 21 | var ( 22 | MetaState MetaType = 0 23 | MetaDuration MetaType = 1 24 | 25 | durationPattern = regexp.MustCompile(`^\d+ minutes$`) 26 | 27 | functions = map[string]govaluate.ExpressionFunction{ 28 | "contains": func(args ...any) (any, error) { 29 | if len(args) != 2 { 30 | return nil, fmt.Errorf("contains() accepts exactly two arguments") 31 | } 32 | idx := strings.Index(args[0].(string), args[1].(string)) 33 | return bool(idx > -1), nil 34 | }, 35 | "lower": func(args ...any) (any, error) { 36 | if len(args) != 1 { 37 | return nil, fmt.Errorf("lower() accepts exactly one arguments") 38 | } 39 | lowered := strings.ToLower(args[0].(string)) 40 | return string(lowered), nil 41 | }, 42 | "upper": func(args ...any) (any, error) { 43 | if len(args) != 1 { 44 | return nil, fmt.Errorf("upper() accepts exactly one arguments") 45 | } 46 | uppered := strings.ToUpper(args[0].(string)) 47 | return string(uppered), nil 48 | }, 49 | } 50 | ) 51 | 52 | // Goroutine contains a goroutine info. 53 | type Goroutine struct { 54 | id int 55 | header string 56 | trace string 57 | lines int 58 | duration int // In minutes. 59 | metas map[MetaType]string 60 | 61 | lineMd5 []string 62 | fullMd5 string 63 | fullHasher hash.Hash 64 | duplicates []int 65 | 66 | frozen bool 67 | buf *bytes.Buffer 68 | } 69 | 70 | // AddLine appends a line to the goroutine info. 71 | func (g *Goroutine) AddLine(l string) { 72 | if !g.frozen { 73 | g.lines++ 74 | g.buf.WriteString(l) 75 | g.buf.WriteString("\n") 76 | 77 | if strings.HasPrefix(l, "\t") || strings.HasPrefix(l, " ") { 78 | 79 | // sigquit dumps include fp, sp, and pc for each line, so we only 80 | // want the line itself here 81 | l = strings.TrimSpace(l) 82 | parts := strings.Split(l, " ") 83 | fl := parts[0] 84 | 85 | h := md5.New() 86 | io.WriteString(h, fl) //nolint:errcheck 87 | g.lineMd5 = append(g.lineMd5, string(h.Sum(nil))) 88 | 89 | io.WriteString(g.fullHasher, fl) //nolint:errcheck 90 | } 91 | } 92 | } 93 | 94 | // Freeze freezes the goroutine info. 95 | func (g *Goroutine) Freeze() { 96 | if !g.frozen { 97 | g.frozen = true 98 | g.trace = g.buf.String() 99 | g.buf = nil 100 | 101 | g.fullMd5 = string(g.fullHasher.Sum(nil)) 102 | } 103 | } 104 | 105 | // Print outputs the goroutine details to w. 106 | func (g Goroutine) Print(w io.Writer) error { 107 | if _, err := fmt.Fprint(w, g.header); err != nil { 108 | return err 109 | } 110 | if len(g.duplicates) > 0 { 111 | if _, err := fmt.Fprintf(w, " %d times: [[", len(g.duplicates)); err != nil { 112 | return err 113 | } 114 | for i, id := range g.duplicates { 115 | if i > 0 { 116 | if _, err := fmt.Fprint(w, ", "); err != nil { 117 | return err 118 | } 119 | } 120 | if _, err := fmt.Fprint(w, id); err != nil { 121 | return err 122 | } 123 | } 124 | if _, err := fmt.Fprint(w, "]"); err != nil { 125 | return err 126 | } 127 | } 128 | if _, err := fmt.Fprintln(w); err != nil { 129 | return err 130 | } 131 | if _, err := fmt.Fprintln(w, g.trace); err != nil { 132 | return err 133 | } 134 | return nil 135 | } 136 | 137 | // PrintWithColor outputs the goroutine details to stdout with color. 138 | // 139 | //nolint:errcheck 140 | func (g Goroutine) PrintWithColor(w io.Writer) { 141 | io.WriteString(w, fmt.Sprintf("%s%s%s", 142 | fgBlue, g.header, reset)) 143 | if len(g.duplicates) > 0 { 144 | io.WriteString(w, fmt.Sprintf(" %s%d%s times: [", 145 | fgRed, len(g.duplicates), reset)) 146 | for i, id := range g.duplicates { 147 | if i > 0 { 148 | io.WriteString(w, ", ") 149 | } 150 | io.WriteString(w, fmt.Sprintf("%s%d%s", 151 | fgGreen, id, reset)) 152 | } 153 | io.WriteString(w, "]") 154 | } 155 | io.WriteString(w, "\n") 156 | io.WriteString(w, g.trace+"\n") 157 | } 158 | 159 | // NewGoroutine creates and returns a new Goroutine. 160 | func NewGoroutine(metaline string) (*Goroutine, error) { 161 | idx := strings.Index(metaline, "[") 162 | parts := strings.Split(metaline[idx+1:len(metaline)-2], ",") 163 | metas := map[MetaType]string{ 164 | MetaState: strings.TrimSpace(parts[0]), 165 | } 166 | 167 | duration := 0 168 | if len(parts) > 1 { 169 | value := strings.TrimSpace(parts[1]) 170 | metas[MetaDuration] = value 171 | if durationPattern.MatchString(value) { 172 | if d, err := strconv.Atoi(value[:len(value)-8]); err == nil { 173 | duration = d 174 | } 175 | } 176 | } 177 | 178 | // TODO: this throws out the "gp=", "m=", and "mp=" fields we see on a 179 | // SIGQUIT. We should have searchable fields for these as well. 180 | idxParts := strings.Split(strings.TrimSpace(metaline[9:idx]), " ") 181 | idstr := strings.TrimSpace(idxParts[0]) 182 | id, err := strconv.Atoi(idstr) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | return &Goroutine{ 188 | id: id, 189 | lines: 1, 190 | header: metaline, 191 | buf: &bytes.Buffer{}, 192 | duration: duration, 193 | metas: metas, 194 | fullHasher: md5.New(), 195 | duplicates: []int{}, 196 | }, nil 197 | } 198 | 199 | // GoroutineDump defines a goroutine dump. 200 | type GoroutineDump struct { 201 | goroutines []*Goroutine 202 | w io.Writer 203 | } 204 | 205 | // Add appends a goroutine info to the list. 206 | func (gd *GoroutineDump) Add(g *Goroutine) { 207 | gd.goroutines = append(gd.goroutines, g) 208 | } 209 | 210 | // Copy duplicates and returns the GoroutineDump. 211 | func (gd GoroutineDump) Copy(cond string) *GoroutineDump { 212 | dump := GoroutineDump{ 213 | w: gd.w, 214 | goroutines: []*Goroutine{}, 215 | } 216 | if cond == "" { 217 | // Copy all. 218 | dump.goroutines = append(dump.goroutines, gd.goroutines...) 219 | } else { 220 | goroutines, err := gd.withCondition(cond, func(i int, g *Goroutine, passed bool) *Goroutine { 221 | if passed { 222 | return g 223 | } 224 | return nil 225 | }) 226 | if err != nil { 227 | gd.PrintErr(err) 228 | return nil 229 | } 230 | dump.goroutines = goroutines 231 | } 232 | return &dump 233 | } 234 | 235 | func (gd GoroutineDump) PrintErr(err error) { 236 | io.WriteString(gd.w, err.Error()+"\n") //nolint:errcheck 237 | } 238 | 239 | // Print formats the string and always includes the trailing newline 240 | func (gd GoroutineDump) Print(s string, args ...any) { 241 | io.WriteString(gd.w, fmt.Sprintf(s, args...)+"\n") //nolint:errcheck 242 | } 243 | 244 | // Dedup finds goroutines with duplicated stack traces and keeps only one copy 245 | // of them. 246 | func (gd *GoroutineDump) Dedup() { 247 | m := map[string][]int{} 248 | for _, g := range gd.goroutines { 249 | m[g.fullMd5] = append(m[g.fullMd5], g.id) 250 | } 251 | 252 | kept := make([]*Goroutine, 0, len(gd.goroutines)) 253 | 254 | outter: 255 | for digest, ids := range m { 256 | for _, g := range gd.goroutines { 257 | if g.fullMd5 == digest { 258 | g.duplicates = ids 259 | kept = append(kept, g) 260 | continue outter 261 | } 262 | } 263 | } 264 | 265 | if len(gd.goroutines) != len(kept) { 266 | gd.Print( 267 | "Dedupped %d, kept %d", len(gd.goroutines), len(kept)) 268 | gd.goroutines = kept 269 | } 270 | } 271 | 272 | // Delete deletes by the condition. 273 | func (gd *GoroutineDump) Delete(cond string) error { 274 | goroutines, err := gd.withCondition(cond, func(i int, g *Goroutine, passed bool) *Goroutine { 275 | if !passed { 276 | return g 277 | } 278 | return nil 279 | }) 280 | if err != nil { 281 | return err 282 | } 283 | gd.goroutines = goroutines 284 | return nil 285 | } 286 | 287 | // Diff shows the difference between two dumps. 288 | func (gd *GoroutineDump) Diff(another *GoroutineDump) (*GoroutineDump, *GoroutineDump, *GoroutineDump) { 289 | lonly := map[int]*Goroutine{} 290 | ronly := map[int]*Goroutine{} 291 | common := map[int]*Goroutine{} 292 | 293 | for _, v := range gd.goroutines { 294 | lonly[v.id] = v 295 | } 296 | for _, v := range another.goroutines { 297 | if _, ok := lonly[v.id]; ok { 298 | delete(lonly, v.id) 299 | common[v.id] = v 300 | } else { 301 | ronly[v.id] = v 302 | } 303 | } 304 | return NewGoroutineDumpFromMap(lonly, gd.w), 305 | NewGoroutineDumpFromMap(common, gd.w), 306 | NewGoroutineDumpFromMap(ronly, gd.w) 307 | } 308 | 309 | // Keep keeps by the condition. 310 | func (gd *GoroutineDump) Keep(cond string) error { 311 | goroutines, err := gd.withCondition(cond, func(i int, g *Goroutine, passed bool) *Goroutine { 312 | if passed { 313 | return g 314 | } 315 | return nil 316 | }) 317 | if err != nil { 318 | return err 319 | } 320 | gd.goroutines = goroutines 321 | return nil 322 | } 323 | 324 | // Save saves the goroutine dump to the given file. 325 | func (gd GoroutineDump) Save(fn string) error { 326 | f, err := os.Create(fn) 327 | if err != nil { 328 | return err 329 | } 330 | defer f.Close() 331 | 332 | for _, g := range gd.goroutines { 333 | if err := g.Print(f); err != nil { 334 | return err 335 | } 336 | } 337 | 338 | gd.Print("Goroutines are saved to file %s", fn) 339 | return nil 340 | } 341 | 342 | // Search displays the goroutines with the offset and limit. 343 | func (gd GoroutineDump) Search(cond string, offset, limit int) { 344 | gd.Print( 345 | "%sSearch with offset %d and limit %d.%s\n", 346 | fgGreen, offset, limit, reset) 347 | 348 | count := 0 349 | _, err := gd.withCondition(cond, func(i int, g *Goroutine, passed bool) *Goroutine { 350 | if passed { 351 | if count >= offset && count < offset+limit { 352 | g.PrintWithColor(gd.w) 353 | } 354 | count++ 355 | } 356 | return nil 357 | }) 358 | if err != nil { 359 | gd.PrintErr(err) 360 | } 361 | } 362 | 363 | // Show displays the goroutines with the offset and limit. 364 | func (gd GoroutineDump) Show(offset, limit int) { 365 | for i := offset; i < offset+limit && i < len(gd.goroutines); i++ { 366 | gd.goroutines[i].PrintWithColor(gd.w) 367 | } 368 | } 369 | 370 | // Sort sorts the goroutine entries. 371 | func (gd *GoroutineDump) Sort() { 372 | gd.Print("# of goroutines: %d", len(gd.goroutines)) 373 | } 374 | 375 | // Summary prints the summary of the goroutine dump. 376 | func (gd GoroutineDump) Summary() { 377 | gd.Print("# of goroutines: %d", len(gd.goroutines)) 378 | stats := map[string]int{} 379 | if len(gd.goroutines) > 0 { 380 | for _, g := range gd.goroutines { 381 | stats[g.metas[MetaState]]++ 382 | } 383 | gd.Print("") // extra newline 384 | } 385 | if len(stats) > 0 { 386 | states := make([]string, 0, 10) 387 | for k := range stats { 388 | states = append(states, k) 389 | } 390 | sort.Strings(states) 391 | 392 | for _, k := range states { 393 | gd.Print("%15s: %d", k, stats[k]) 394 | } 395 | gd.Print("") // extra newline 396 | } 397 | } 398 | 399 | // NewGoroutineDump creates and returns a new GoroutineDump. 400 | func NewGoroutineDump(w io.Writer) *GoroutineDump { 401 | return &GoroutineDump{ 402 | goroutines: []*Goroutine{}, 403 | w: w, 404 | } 405 | } 406 | 407 | // NewGoroutineDumpFromMap creates and returns a new GoroutineDump from a map. 408 | func NewGoroutineDumpFromMap(gs map[int]*Goroutine, w io.Writer) *GoroutineDump { 409 | gd := &GoroutineDump{ 410 | goroutines: []*Goroutine{}, 411 | w: w, 412 | } 413 | for _, v := range gs { 414 | gd.goroutines = append(gd.goroutines, v) 415 | } 416 | return gd 417 | } 418 | 419 | func (gd *GoroutineDump) withCondition(cond string, callback func(int, *Goroutine, bool) *Goroutine) ([]*Goroutine, error) { 420 | cond = strings.Trim(cond, "\"") 421 | expression, err := govaluate.NewEvaluableExpressionWithFunctions(cond, functions) 422 | if err != nil { 423 | return nil, err 424 | } 425 | 426 | goroutines := make([]*Goroutine, 0, len(gd.goroutines)) 427 | for i, g := range gd.goroutines { 428 | params := map[string]any{ 429 | "id": g.id, 430 | "dups": len(g.duplicates), 431 | "duration": g.duration, 432 | "lines": g.lines, 433 | "state": g.metas[MetaState], 434 | "trace": g.trace, 435 | } 436 | res, err := expression.Evaluate(params) 437 | if err != nil { 438 | return nil, err 439 | } 440 | if val, ok := res.(bool); ok { 441 | if gor := callback(i, g, val); gor != nil { 442 | goroutines = append(goroutines, gor) 443 | } 444 | } else { 445 | return nil, errors.New("argument expression should return a boolean") 446 | } 447 | } 448 | // TODO: let the caller pass in a format string so that we can get 449 | // nicer output based on the command being used 450 | gd.Print( 451 | "Filtered %d goroutines, kept %d.", 452 | len(gd.goroutines)-len(goroutines), len(goroutines)) 453 | return goroutines, nil 454 | } 455 | -------------------------------------------------------------------------------- /goroutine_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/mattn/go-colorable" 9 | "github.com/shoenig/test/must" 10 | ) 11 | 12 | const dummyGoroutineMetaTmpl = `goroutine %d [%s]:` 13 | 14 | func TestShowOffset(t *testing.T) { 15 | 16 | var buf bytes.Buffer 17 | dump := NewGoroutineDump(colorable.NewNonColorable(&buf)) 18 | for i := 0; i < 20; i++ { 19 | gr, err := NewGoroutine(fmt.Sprintf(dummyGoroutineMetaTmpl, i, "running")) 20 | must.NoError(t, err) 21 | dump.goroutines = append(dump.goroutines, gr) 22 | } 23 | 24 | getIDs := func(t *testing.T, buf bytes.Buffer) []int { 25 | t.Helper() 26 | got := []int{} 27 | out, err := loadFrom(&buf) 28 | must.NoError(t, err) 29 | for _, goroutine := range out.goroutines { 30 | got = append(got, goroutine.id) 31 | } 32 | return got 33 | } 34 | 35 | dump.Show(0, 10) 36 | must.Eq(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, getIDs(t, buf)) 37 | 38 | buf.Reset() 39 | dump.Show(0, 25) 40 | must.Eq(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 41 | 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 42 | }, getIDs(t, buf)) 43 | 44 | buf.Reset() 45 | dump.Show(10, 5) 46 | must.Eq(t, []int{10, 11, 12, 13, 14}, getIDs(t, buf)) 47 | 48 | buf.Reset() 49 | dump.Show(10, 20) 50 | must.Eq(t, []int{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, getIDs(t, buf)) 51 | 52 | } 53 | 54 | func TestSearchOffset(t *testing.T) { 55 | 56 | var buf bytes.Buffer 57 | dump := NewGoroutineDump(colorable.NewNonColorable(&buf)) 58 | states := []string{"running", "select"} 59 | for i := 0; i < 20; i++ { 60 | gr, err := NewGoroutine(fmt.Sprintf(dummyGoroutineMetaTmpl, i, states[i%2])) 61 | must.NoError(t, err) 62 | dump.goroutines = append(dump.goroutines, gr) 63 | } 64 | 65 | getIDs := func(t *testing.T, buf bytes.Buffer) []int { 66 | t.Helper() 67 | got := []int{} 68 | out, err := loadFrom(&buf) 69 | must.NoError(t, err) 70 | for _, goroutine := range out.goroutines { 71 | got = append(got, goroutine.id) 72 | } 73 | return got 74 | } 75 | 76 | dump.Search("state == 'select'", 0, 30) 77 | must.Eq(t, []int{1, 3, 5, 7, 9, 11, 13, 15, 17, 19}, getIDs(t, buf)) 78 | 79 | buf.Reset() 80 | dump.Search("state == 'select'", 0, 5) 81 | must.Eq(t, []int{1, 3, 5, 7, 9}, getIDs(t, buf)) 82 | 83 | buf.Reset() 84 | dump.Search("state == 'select'", 5, 4) 85 | must.Eq(t, []int{11, 13, 15, 17}, getIDs(t, buf)) 86 | } 87 | -------------------------------------------------------------------------------- /load.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/mattn/go-colorable" 12 | ) 13 | 14 | var ( 15 | startLinePattern = regexp.MustCompile(`^goroutine\s+(\d+)\s+.*\[(.*)\]:$`) 16 | ) 17 | 18 | func load(fn string) (*GoroutineDump, error) { 19 | fn = strings.Trim(fn, "\"") 20 | 21 | if strings.HasPrefix(fn, "~") { 22 | home, _ := os.UserHomeDir() 23 | fn = filepath.Join(home, fn[1:]) 24 | } 25 | fn = os.ExpandEnv(fn) 26 | 27 | f, err := os.Open(fn) 28 | if err != nil { 29 | return nil, err 30 | } 31 | defer f.Close() 32 | return loadFrom(f) 33 | } 34 | 35 | func loadFrom(r io.Reader) (*GoroutineDump, error) { 36 | dump := NewGoroutineDump(colorable.NewColorableStdout()) 37 | 38 | var goroutine *Goroutine 39 | var err error 40 | 41 | scanner := bufio.NewScanner(r) 42 | for scanner.Scan() { 43 | line := scanner.Text() 44 | if startLinePattern.MatchString(line) { 45 | // Freeze any previous goroutine to tolerate dumps without line 46 | // breaks 47 | if goroutine != nil { 48 | goroutine.Freeze() 49 | } 50 | 51 | goroutine, err = NewGoroutine(line) 52 | if err != nil { 53 | return nil, err 54 | } 55 | dump.Add(goroutine) 56 | } else if line == "" { 57 | // End of a goroutine section. 58 | if goroutine != nil { 59 | goroutine.Freeze() 60 | } 61 | goroutine = nil 62 | } else if goroutine != nil { 63 | goroutine.AddLine(line) 64 | } 65 | } 66 | 67 | if goroutine != nil { 68 | goroutine.Freeze() 69 | } 70 | 71 | if err := scanner.Err(); err != nil { 72 | return nil, err 73 | } 74 | return dump, nil 75 | } 76 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "sort" 11 | "strings" 12 | 13 | "github.com/peterh/liner" 14 | ) 15 | 16 | var ( 17 | assignPattern = regexp.MustCompile(`^\s*[_a-zA-Z][_a-zA-Z0-9]*(\s*,\s*[_a-zA-Z][_a-zA-Z0-9]*)*\s*=\s*.*$`) 18 | cdPattern = regexp.MustCompile(`^\s*cd\s*.*$`) 19 | 20 | commands = map[string]string{ 21 | "?": "Show this help", 22 | "cd": "Change current working directory", 23 | "clear": "Clear the workspace", 24 | "exit": "Exit the interactive shell", 25 | "help": "Show this help", 26 | "ls": "Show files in current directory", 27 | "pwd": "Show current working directory", 28 | "quit": "Quit the interactive shell", 29 | "whos": "Show all varaibles in workspace", 30 | } 31 | cmds []string 32 | line *liner.State 33 | 34 | workspace = map[string]*GoroutineDump{} 35 | ) 36 | 37 | func init() { 38 | cmds = make([]string, 0, len(commands)) 39 | for k := range commands { 40 | cmds = append(cmds, k) 41 | } 42 | sort.Strings(cmds) 43 | } 44 | 45 | func main() { 46 | var err error 47 | line, err = createLiner() 48 | if err != nil { 49 | fmt.Println("could not read history file: %w", err) 50 | } 51 | 52 | defer func() { 53 | err := saveLiner(line) 54 | if err != nil { 55 | fmt.Println("could not save history file: %w", err) 56 | } 57 | line.Close() 58 | }() 59 | 60 | for { 61 | if cmd, err := line.Prompt(">> "); err == nil { 62 | cmd = strings.TrimSpace(cmd) 63 | if cmd == "" { 64 | continue 65 | } 66 | line.AppendHistory(cmd) 67 | 68 | switch cmd { 69 | case "?", "help": 70 | printHelp() 71 | case "clear": 72 | workspace = map[string]*GoroutineDump{} 73 | fmt.Println("Workspace cleared.") 74 | case "exit", "quit": 75 | return 76 | case "ls": 77 | wd, err := os.Getwd() 78 | if err != nil { 79 | fmt.Println(err) 80 | continue 81 | } 82 | printDir(wd) 83 | case "pwd": 84 | wd, err := os.Getwd() 85 | if err != nil { 86 | fmt.Println(err) 87 | continue 88 | } 89 | fmt.Println(wd) 90 | case "whos": 91 | if len(workspace) == 0 { 92 | fmt.Println("No variables defined.") 93 | continue 94 | } 95 | for k := range workspace { 96 | fmt.Printf("%s\t", k) 97 | } 98 | fmt.Println() 99 | default: 100 | if cdPattern.MatchString(cmd) { 101 | // Change directory. 102 | idx := strings.Index(cmd, "cd") 103 | dir := strings.TrimSpace(cmd[idx+2:]) 104 | if dir == "" { 105 | fmt.Println("Expect command \"cd