Skip to content

Commit 6793919

Browse files
authored
feat_: LogOnPanic linter (status-im#5969)
* feat_: LogOnPanic linter * fix_: add missing defer LogOnPanic * chore_: make vendor * fix_: tests, address pr comments * fix_: address pr comments
1 parent 0555331 commit 6793919

File tree

180 files changed

+28220
-8
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

180 files changed

+28220
-8
lines changed

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,10 @@ canary-test: node-canary
408408
# TODO: uncomment that!
409409
#_assets/scripts/canary_test_mailservers.sh ./config/cli/fleet-eth.prod.json
410410

411-
lint: generate
411+
lint-panics: generate
412+
go run ./cmd/lint-panics -root="$(call sh, pwd)" -skip=./cmd -test=false ./...
413+
414+
lint: generate lint-panics
412415
golangci-lint run ./...
413416

414417
ci: generate lint canary-test test-unit test-e2e ##@tests Run all linters and tests at once

cmd/lint-panics/analyzer/analyzer.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package analyzer
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"go/ast"
7+
"os"
8+
9+
"go.uber.org/zap"
10+
11+
goparser "go/parser"
12+
gotoken "go/token"
13+
"strings"
14+
15+
"github.com/pkg/errors"
16+
"golang.org/x/tools/go/analysis"
17+
"golang.org/x/tools/go/analysis/passes/inspect"
18+
"golang.org/x/tools/go/ast/inspector"
19+
20+
"github.com/status-im/status-go/cmd/lint-panics/gopls"
21+
"github.com/status-im/status-go/cmd/lint-panics/utils"
22+
)
23+
24+
const Pattern = "LogOnPanic"
25+
26+
type Analyzer struct {
27+
logger *zap.Logger
28+
lsp LSP
29+
cfg *Config
30+
}
31+
32+
type LSP interface {
33+
Definition(context.Context, string, int, int) (string, int, error)
34+
}
35+
36+
func New(ctx context.Context, logger *zap.Logger) (*analysis.Analyzer, error) {
37+
cfg := Config{}
38+
flags, err := cfg.ParseFlags()
39+
if err != nil {
40+
return nil, err
41+
}
42+
43+
logger.Info("creating analyzer", zap.String("root", cfg.RootDir))
44+
45+
goplsClient := gopls.NewGoplsClient(ctx, logger, cfg.RootDir)
46+
processor := newAnalyzer(logger, goplsClient, &cfg)
47+
48+
analyzer := &analysis.Analyzer{
49+
Name: "logpanics",
50+
Doc: fmt.Sprintf("reports missing defer call to %s", Pattern),
51+
Flags: flags,
52+
Requires: []*analysis.Analyzer{inspect.Analyzer},
53+
Run: func(pass *analysis.Pass) (interface{}, error) {
54+
return processor.Run(ctx, pass)
55+
},
56+
}
57+
58+
return analyzer, nil
59+
}
60+
61+
func newAnalyzer(logger *zap.Logger, lsp LSP, cfg *Config) *Analyzer {
62+
return &Analyzer{
63+
logger: logger.Named("processor"),
64+
lsp: lsp,
65+
cfg: cfg.WithAbsolutePaths(),
66+
}
67+
}
68+
69+
func (p *Analyzer) Run(ctx context.Context, pass *analysis.Pass) (interface{}, error) {
70+
inspected, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
71+
if !ok {
72+
return nil, errors.New("analyzer is not type *inspector.Inspector")
73+
}
74+
75+
// Create a nodes filter for goroutines (GoStmt represents a 'go' statement)
76+
nodeFilter := []ast.Node{
77+
(*ast.GoStmt)(nil),
78+
}
79+
80+
// Inspect go statements
81+
inspected.Preorder(nodeFilter, func(n ast.Node) {
82+
p.ProcessNode(ctx, pass, n)
83+
})
84+
85+
return nil, nil
86+
}
87+
88+
func (p *Analyzer) ProcessNode(ctx context.Context, pass *analysis.Pass, n ast.Node) {
89+
goStmt, ok := n.(*ast.GoStmt)
90+
if !ok {
91+
panic("unexpected node type")
92+
}
93+
94+
switch fun := goStmt.Call.Fun.(type) {
95+
case *ast.FuncLit: // anonymous function
96+
pos := pass.Fset.Position(fun.Pos())
97+
logger := p.logger.With(
98+
utils.ZapURI(pos.Filename, pos.Line),
99+
zap.Int("column", pos.Column),
100+
)
101+
102+
logger.Debug("found anonymous goroutine")
103+
if err := p.checkGoroutine(fun.Body); err != nil {
104+
p.logLinterError(pass, fun.Pos(), fun.Pos(), err)
105+
}
106+
107+
case *ast.SelectorExpr: // method call
108+
pos := pass.Fset.Position(fun.Sel.Pos())
109+
p.logger.Info("found method call as goroutine",
110+
zap.String("methodName", fun.Sel.Name),
111+
utils.ZapURI(pos.Filename, pos.Line),
112+
zap.Int("column", pos.Column),
113+
)
114+
115+
defPos, err := p.checkGoroutineDefinition(ctx, pos, pass)
116+
if err != nil {
117+
p.logLinterError(pass, defPos, fun.Sel.Pos(), err)
118+
}
119+
120+
case *ast.Ident: // function call
121+
pos := pass.Fset.Position(fun.Pos())
122+
p.logger.Info("found function call as goroutine",
123+
zap.String("functionName", fun.Name),
124+
utils.ZapURI(pos.Filename, pos.Line),
125+
zap.Int("column", pos.Column),
126+
)
127+
128+
defPos, err := p.checkGoroutineDefinition(ctx, pos, pass)
129+
if err != nil {
130+
p.logLinterError(pass, defPos, fun.Pos(), err)
131+
}
132+
133+
default:
134+
p.logger.Error("unexpected goroutine type",
135+
zap.String("type", fmt.Sprintf("%T", fun)),
136+
)
137+
}
138+
}
139+
140+
func (p *Analyzer) parseFile(path string, pass *analysis.Pass) (*ast.File, error) {
141+
logger := p.logger.With(zap.String("path", path))
142+
143+
src, err := os.ReadFile(path)
144+
if err != nil {
145+
logger.Error("failed to open file", zap.Error(err))
146+
}
147+
148+
file, err := goparser.ParseFile(pass.Fset, path, src, 0)
149+
if err != nil {
150+
logger.Error("failed to parse file", zap.Error(err))
151+
return nil, err
152+
}
153+
154+
return file, nil
155+
}
156+
157+
func (p *Analyzer) checkGoroutine(body *ast.BlockStmt) error {
158+
if body == nil {
159+
p.logger.Warn("missing function body")
160+
return nil
161+
}
162+
if len(body.List) == 0 {
163+
// empty goroutine is weird, but it never panics, so not a linter error
164+
return nil
165+
}
166+
167+
deferStatement, ok := body.List[0].(*ast.DeferStmt)
168+
if !ok {
169+
return errors.New("first statement is not defer")
170+
}
171+
172+
selectorExpr, ok := deferStatement.Call.Fun.(*ast.SelectorExpr)
173+
if !ok {
174+
return errors.New("first statement call is not a selector")
175+
}
176+
177+
firstLineFunName := selectorExpr.Sel.Name
178+
if firstLineFunName != Pattern {
179+
return errors.Errorf("first statement is not %s", Pattern)
180+
}
181+
182+
return nil
183+
}
184+
185+
func (p *Analyzer) getFunctionBody(node ast.Node, lineNumber int, pass *analysis.Pass) (body *ast.BlockStmt, pos gotoken.Pos) {
186+
ast.Inspect(node, func(n ast.Node) bool {
187+
// Check if the node is a function declaration
188+
funcDecl, ok := n.(*ast.FuncDecl)
189+
if !ok {
190+
return true
191+
}
192+
193+
if pass.Fset.Position(n.Pos()).Line != lineNumber {
194+
return true
195+
}
196+
197+
body = funcDecl.Body
198+
pos = n.Pos()
199+
return false
200+
})
201+
202+
return body, pos
203+
204+
}
205+
206+
func (p *Analyzer) checkGoroutineDefinition(ctx context.Context, pos gotoken.Position, pass *analysis.Pass) (gotoken.Pos, error) {
207+
defFilePath, defLineNumber, err := p.lsp.Definition(ctx, pos.Filename, pos.Line, pos.Column)
208+
if err != nil {
209+
p.logger.Error("failed to find function definition", zap.Error(err))
210+
return 0, err
211+
}
212+
213+
file, err := p.parseFile(defFilePath, pass)
214+
if err != nil {
215+
p.logger.Error("failed to parse file", zap.Error(err))
216+
return 0, err
217+
}
218+
219+
body, defPosition := p.getFunctionBody(file, defLineNumber, pass)
220+
return defPosition, p.checkGoroutine(body)
221+
}
222+
223+
func (p *Analyzer) logLinterError(pass *analysis.Pass, errPos gotoken.Pos, callPos gotoken.Pos, err error) {
224+
errPosition := pass.Fset.Position(errPos)
225+
callPosition := pass.Fset.Position(callPos)
226+
227+
if p.skip(errPosition.Filename) || p.skip(callPosition.Filename) {
228+
return
229+
}
230+
231+
message := fmt.Sprintf("missing %s()", Pattern)
232+
p.logger.Warn(message,
233+
utils.ZapURI(errPosition.Filename, errPosition.Line),
234+
zap.String("details", err.Error()))
235+
236+
if callPos == errPos {
237+
pass.Reportf(errPos, "missing defer call to %s", Pattern)
238+
} else {
239+
pass.Reportf(callPos, "missing defer call to %s", Pattern)
240+
}
241+
}
242+
243+
func (p *Analyzer) skip(filepath string) bool {
244+
return p.cfg.SkipDir != "" && strings.HasPrefix(filepath, p.cfg.SkipDir)
245+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package analyzer
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
"go.uber.org/zap"
10+
11+
"golang.org/x/tools/go/analysis/analysistest"
12+
13+
"github.com/status-im/status-go/cmd/lint-panics/utils"
14+
)
15+
16+
func TestMethods(t *testing.T) {
17+
t.Parallel()
18+
19+
logger := utils.BuildLogger(zap.DebugLevel)
20+
21+
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
22+
defer cancel()
23+
24+
a, err := New(ctx, logger)
25+
require.NoError(t, err)
26+
27+
analysistest.Run(t, analysistest.TestData(), a, "functions")
28+
}

cmd/lint-panics/analyzer/config.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package analyzer
2+
3+
import (
4+
"flag"
5+
"io"
6+
"os"
7+
"path"
8+
"strings"
9+
)
10+
11+
type Config struct {
12+
RootDir string
13+
SkipDir string
14+
}
15+
16+
var workdir string
17+
18+
func init() {
19+
var err error
20+
workdir, err = os.Getwd()
21+
if err != nil {
22+
panic(err)
23+
}
24+
}
25+
26+
func (c *Config) ParseFlags() (flag.FlagSet, error) {
27+
flags := flag.NewFlagSet("lint-panics", flag.ContinueOnError)
28+
flags.SetOutput(io.Discard) // Otherwise errors are printed to stderr
29+
flags.StringVar(&c.RootDir, "root", workdir, "root directory to run gopls")
30+
flags.StringVar(&c.SkipDir, "skip", "", "skip paths with this suffix")
31+
32+
// We parse the flags here to have `rootDir` before the call to `singlechecker.Main(analyzer)`
33+
// For same reasons we discard the output and skip the undefined flag error.
34+
err := flags.Parse(os.Args[1:])
35+
if err == nil {
36+
return *flags, nil
37+
}
38+
39+
if strings.Contains(err.Error(), "flag provided but not defined") {
40+
err = nil
41+
} else if strings.Contains(err.Error(), "help requested") {
42+
err = nil
43+
}
44+
45+
return *flags, err
46+
}
47+
48+
func (c *Config) WithAbsolutePaths() *Config {
49+
out := *c
50+
51+
if !path.IsAbs(out.RootDir) {
52+
out.RootDir = path.Join(workdir, out.RootDir)
53+
}
54+
55+
if out.SkipDir != "" && !path.IsAbs(out.SkipDir) {
56+
out.SkipDir = path.Join(out.RootDir, out.SkipDir)
57+
}
58+
59+
return &out
60+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package common
2+
3+
func LogOnPanic() {
4+
// do nothing
5+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package functions
2+
3+
import (
4+
"common"
5+
"fmt"
6+
)
7+
8+
func init() {
9+
go func() {
10+
defer common.LogOnPanic()
11+
}()
12+
13+
go func() {
14+
15+
}()
16+
17+
go func() { // want "missing defer call to LogOnPanic"
18+
fmt.Println("anon")
19+
}()
20+
21+
go func() { // want "missing defer call to LogOnPanic"
22+
common.LogOnPanic()
23+
}()
24+
}

0 commit comments

Comments
 (0)