Skip to content

feat(pkg/paralleltestctx): generalise to catch outer-var reassignment in parallel subtests#4

Merged
ethanndickson merged 3 commits into
mainfrom
feat/check-outer-assignments
Jun 22, 2026
Merged

feat(pkg/paralleltestctx): generalise to catch outer-var reassignment in parallel subtests#4
ethanndickson merged 3 commits into
mainfrom
feat/check-outer-assignments

Conversation

@ethanndickson

@ethanndickson ethanndickson commented Jun 9, 2026

Copy link
Copy Markdown
Member

Generalises the linter to catch an adjacent flake pattern: an outer-scope variable reassigned with = inside a parallel subtest. Sibling parallel subtests can race on the shared variable, and the fix is almost always to shadow with :=.

Motivation

Mirrors the bug shape from coder/coder#25980:

func TestX(t *testing.T) {
    t.Parallel()
    var err error
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            _, err = doThing(tc)   // shared across siblings
            require.NoError(t, err)
        })
    }
}

The existing context-specific rule already emits the exact diagnostic this needs ("did you mean to shadow the variable?") for the context.Context case. This change applies the same idea to any outer-scope *types.Var.

Rule

A diagnostic fires when all of these hold:

  1. The enclosing scope contains a t.Parallel() call.
  2. The assignment uses =, not :=.
  3. The LHS is an *ast.Ident (not SelectorExpr, IndexExpr, etc., so outer.Field = x and outer[k] = x do not fire).
  4. The Ident resolves to a *types.Var declared outside the current scope.
  5. The object isn't already tracked by the existing timeout-context rule (avoids double diagnostics).

Default behaviour

The check runs unconditionally alongside the existing context-specific rule. There is no flag. Existing users will see new diagnostics on their next run.

I originally gated this behind -check-outer-assignments (off by default), then dropped the gate after the empirical evaluation below.

Empirical evaluation

Ran against the full coder/coder repo (930 test files): 4 findings, 0 false positives. The diagnostic's suggested := shadow is the correct fix in each case:

Location Verdict
coderd/mcp/mcp_e2e_test.go:533 (err = mcpClient.Start(ctx)) True positive — same shape as coder/coder#25980
scripts/workspace-runtime-audit/runtimeaudit_test.go:36,39 (runtimeAuditScript = strings.ReplaceAll(...)) Package-level //go:embed var mutated inside a parallel test
agent/unit/graph_test.go:225 (graph = testFunc(t)) Loop-local var graph should move into the closure as graph := …

I also verified the rule catches the exact line PR #25980 fixed by temporarily reverting that one character locally.

Considered, not shipped

  • A "must also be read in same scope" follow-up walk. Empirically dead code on coder/coder (it didn't suppress any of the 4 findings). Could be added later if a real corpus shows write-only fan-in FPs.
  • Detecting an enclosing mu.Lock()/Unlock() pair to suppress synchronized accumulators. Brittle (people use sync.RWMutex, defer mu.Unlock(), helper wrappers); didn't appear in the empirical sweep. Recommend //nolint comments instead if the case comes up.

Add an opt-in flag that flags an adjacent flake pattern: an outer-scope
variable reassigned with `=` inside a parallel subtest. Sibling parallel
subtests can race on the shared variable, and the fix is almost always
to shadow with `:=`.

Example of the bug shape this catches (mirrors coder/coder#25980):

    func TestX(t *testing.T) {
        t.Parallel()
        var err error
        for _, tc := range cases {
            t.Run(tc.name, func(t *testing.T) {
                t.Parallel()
                _, err = doThing(tc)   // shared across siblings
                require.NoError(t, err)
            })
        }
    }

The check is gated behind `-check-outer-assignments` (default off) so
existing users see no behavior change.

Rule:

  1. Enclosing scope contains a t.Parallel() call.
  2. Assignment uses `=` (not `:=`).
  3. LHS is an *ast.Ident (not SelectorExpr, IndexExpr, etc.).
  4. Ident resolves to a *types.Var declared outside the scope.
  5. Object isn't already covered by the timeout-context rule.

Empirical sweep on coder/coder (930 test files): 4 findings, all
actionable (the diagnostic's suggested `:=` shadow is the correct fix
in each case).
Empirical sweep on coder/coder (930 test files) found 4 diagnostics
under this rule, all actionable (the suggested `:=` shadow is the
correct fix in each case). Zero false positives at the actionable-bug
standard, so the opt-in gate is unnecessary friction.

Remove the -check-outer-assignments flag and run the check
unconditionally alongside the existing context rule.
@ethanndickson ethanndickson changed the title feat(pkg/paralleltestctx): add -check-outer-assignments feat(pkg/paralleltestctx): generalise to catch outer-var reassignment in parallel subtests Jun 9, 2026
@ethanndickson ethanndickson merged commit 4ba3b4c into main Jun 22, 2026
3 checks passed
@ethanndickson ethanndickson deleted the feat/check-outer-assignments branch June 22, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant