Skip to content

Commit

Permalink
lint: add loopvarcapture linter
Browse files Browse the repository at this point in the history
This commit introduces a new linter: `loopvarcapture`. It reports
uses of loop variables captured by reference in Go routines or defer
statements, a common source of data races [1].

`govet` currently has a similar linter [2]; however, that project
prioritizes having no false positives at the expense of allowing
false negatives. This linter, on the other hand, represents the
opinion that loop variables should not be captured by reference in Go
routines even when it's safe to do so. That behavior is confusing and
concurrency added to related code over time  could lead to the
introduction of data races, potentially manifesting as bugs in the
product or flakiness in the tests. These issues are hard to debug and
take a lot of developer time.

Developers are still able to use their own judgement and disable this
linter in specific instances by using a `nolint` comment.

[1] A Study of Real-World Data Races in Golang: https://arxiv.org/pdf/2204.00764.pdf
[2] https://github.com/golangci/govet/blob/44ddbe260190d79165f4150b828650780405d801/rangeloop.go#L36

Resolves: #80803.

Release note: None.
  • Loading branch information
renatolabs committed Jun 23, 2022
1 parent b0bb1e2 commit b3d21b2
Show file tree
Hide file tree
Showing 12 changed files with 967 additions and 4 deletions.
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ nogo(
"//pkg/testutils/lint/passes/grpcstatuswithdetails",
"//pkg/testutils/lint/passes/hash",
"//pkg/testutils/lint/passes/leaktestcall",
"//pkg/testutils/lint/passes/loopvarcapture",
"//pkg/testutils/lint/passes/nilness",
"//pkg/testutils/lint/passes/nocopy",
"//pkg/testutils/lint/passes/returncheck",
Expand Down
1 change: 1 addition & 0 deletions pkg/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ ALL_TESTS = [
"//pkg/testutils/lint/passes/forbiddenmethod:forbiddenmethod_test",
"//pkg/testutils/lint/passes/hash:hash_test",
"//pkg/testutils/lint/passes/leaktestcall:leaktestcall_test",
"//pkg/testutils/lint/passes/loopvarcapture:loopvarcapture_test",
"//pkg/testutils/lint/passes/nilness:nilness_test",
"//pkg/testutils/lint/passes/nocopy:nocopy_test",
"//pkg/testutils/lint/passes/passesutil:passesutil_test",
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/roachvet/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ go_library(
"//pkg/testutils/lint/passes/forbiddenmethod",
"//pkg/testutils/lint/passes/hash",
"//pkg/testutils/lint/passes/leaktestcall",
"//pkg/testutils/lint/passes/loopvarcapture",
"//pkg/testutils/lint/passes/nilness",
"//pkg/testutils/lint/passes/nocopy",
"//pkg/testutils/lint/passes/returnerrcheck",
Expand All @@ -28,7 +29,6 @@ go_library(
"@org_golang_x_tools//go/analysis/passes/copylock",
"@org_golang_x_tools//go/analysis/passes/errorsas",
"@org_golang_x_tools//go/analysis/passes/httpresponse",
"@org_golang_x_tools//go/analysis/passes/loopclosure",
"@org_golang_x_tools//go/analysis/passes/lostcancel",
"@org_golang_x_tools//go/analysis/passes/nilfunc",
"@org_golang_x_tools//go/analysis/passes/printf",
Expand Down
6 changes: 4 additions & 2 deletions pkg/cmd/roachvet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/forbiddenmethod"
"github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/hash"
"github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/leaktestcall"
"github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/loopvarcapture"
"github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/nilness"
"github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/nocopy"
"github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/returnerrcheck"
Expand All @@ -36,7 +37,6 @@ import (
"golang.org/x/tools/go/analysis/passes/copylock"
"golang.org/x/tools/go/analysis/passes/errorsas"
"golang.org/x/tools/go/analysis/passes/httpresponse"
"golang.org/x/tools/go/analysis/passes/loopclosure"
"golang.org/x/tools/go/analysis/passes/lostcancel"
"golang.org/x/tools/go/analysis/passes/nilfunc"
"golang.org/x/tools/go/analysis/passes/printf"
Expand Down Expand Up @@ -67,6 +67,7 @@ func main() {
errcmp.Analyzer,
nilness.Analyzer,
errwrap.Analyzer,
loopvarcapture.Analyzer,
)

// Standard go vet analyzers:
Expand All @@ -81,7 +82,8 @@ func main() {
copylock.Analyzer,
errorsas.Analyzer,
httpresponse.Analyzer,
loopclosure.Analyzer,
// loopclosure.Analyzer,
// loopclosure is superseded by 'loopvarcapture'
lostcancel.Analyzer,
nilfunc.Analyzer,
printf.Analyzer,
Expand Down
33 changes: 33 additions & 0 deletions pkg/testutils/lint/passes/loopvarcapture/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "loopvarcapture",
srcs = [
"loop.go",
"loopvarcapture.go",
],
importpath = "github.com/cockroachdb/cockroach/pkg/testutils/lint/passes/loopvarcapture",
visibility = ["//visibility:public"],
deps = [
"//pkg/testutils/lint/passes/passesutil",
"@org_golang_x_tools//go/analysis",
"@org_golang_x_tools//go/analysis/passes/inspect",
"@org_golang_x_tools//go/ast/inspector",
"@org_golang_x_tools//go/types/typeutil",
],
)

go_test(
name = "loopvarcapture_test",
srcs = ["loopvarcapture_test.go"],
data = glob(["testdata/**"]) + [
"@go_sdk//:files",
],
deps = [
":loopvarcapture",
"//pkg/build/bazel",
"//pkg/testutils",
"//pkg/testutils/skip",
"@org_golang_x_tools//go/analysis/analysistest",
],
)
74 changes: 74 additions & 0 deletions pkg/testutils/lint/passes/loopvarcapture/loop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2022 The Cockroach Authors.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

package loopvarcapture

import (
"fmt"
"go/ast"
)

// Loop abstracts away the type of loop (`for` loop with index
// variable vs `range` loops)
type Loop struct {
Vars []*ast.Ident
Body *ast.BlockStmt
}

// NewLoop creates a new Loop struct according to the node passed. If
// the node does not represent either a `for` loop or a `range` loop,
// this function will panic.
func NewLoop(n ast.Node) *Loop {
switch node := n.(type) {
case *ast.ForStmt:
return newForLoop(node)
case *ast.RangeStmt:
return newRange(node)
default:
panic(fmt.Errorf("unexpected loop node: %#v", n))
}
}

// IsEmpty returns whether the loop is empty for the purposes of this
// linter; in other words, whether there no loop variables, or whether
// the loop has zero statements.
func (l *Loop) IsEmpty() bool {
return len(l.Vars) == 0 || len(l.Body.List) == 0
}

func newForLoop(stmt *ast.ForStmt) *Loop {
loop := Loop{Body: stmt.Body}

switch post := stmt.Post.(type) {
case *ast.AssignStmt:
for _, lhs := range post.Lhs {
loop.addVar(lhs)
}

case *ast.IncDecStmt:
loop.addVar(post.X)
}

return &loop
}

func newRange(stmt *ast.RangeStmt) *Loop {
loop := Loop{Body: stmt.Body}
loop.addVar(stmt.Key)
loop.addVar(stmt.Value)

return &loop
}

func (l *Loop) addVar(e ast.Expr) {
if ident, ok := e.(*ast.Ident); ok && ident.Obj != nil {
l.Vars = append(l.Vars, ident)
}
}
Loading

0 comments on commit b3d21b2

Please sign in to comment.