Skip to content

go/types: data race between (*Named).unpack and (*Checker).isComplete under concurrent go/packages export-data loading on go1.27.0 #81122

Open

Description

@buroa

Go version

go version go1.27.0 linux/amd64

Output of go env in your module/workspace

GOOS=linux, GOARCH=amd64, module mode, CGO_ENABLED=1 (race build). Reproduced on Fedora 44, x86_64.

What did you do?

Ran golang.org/x/tools/go/packages.Load (x/tools v0.48.0 and v0.49.0) over a large module (~200 packages, Tests: true) under go test -race, using the deprecated-but-supported mode constant packages.LoadSyntax, in which dependency packages are loaded from export data rather than from source:

cfg := &packages.Config{
        Mode:  packages.LoadSyntax,
        Tests: true,
        Dir:   "<module root>",
}
pkgs, err := packages.Load(cfg, "./...")

The loader type-checks root packages concurrently (one goroutine per package in (*loader).loadPackage). Two concurrently-running go/types checkers touch the same lazily-unpacked *types.Named originating from shared export data.

What did you see happen?

A data race inside go/types itself:

  • Read: go/types/cycles.go:122 — (*Checker).isComplete reading Named.fromRHS
  • Write: go/types/named.go:244 — (*Named).unpack (also via named.go:602) writing the same field

Both goroutines are x/tools go/packages.(*loader).loadPackage workers. Reproduces deterministically within ~20s on the large module. GOMAXPROCS=1 masks it.

What did you expect to see?

No data race: concurrent type-checking of distinct packages whose imports resolve to shared export-data-backed *types.Named values is the documented x/tools go/packages execution model, and go1.26.x is race-clean on the identical module and load configuration.

Notes:

  • A/B isolation on the same tree and toolchain: with the modern explicit mode (NeedName|NeedFiles|NeedCompiledGoFiles|NeedImports|NeedDeps|NeedTypes|NeedSyntax|NeedTypesInfo), where dependencies are type-checked from source, the identical load is race-clean. Only the export-data path (LoadSyntax-style modes) races, pointing at the go1.27 lazy unpack path for export-data-backed named types.
  • A small two-package module does NOT reproduce; a large dependency graph (hundreds of packages, Tests: true) reproduces reliably, consistent with a narrow first-unpack window.
  • Possibly related to the class in runtime: race detector crash on ppc64le #76051 ((*Named).unpack machinery) and the go/packages test disablement in commit 539cdc4 ("A change in go/types has uncovered a race in go/packages").

Activity

  1. buroa commented on Aug 26, 2026

    @buroa
    Author

    Update: reproduces with pure source-mode loading too (no export data) — via the instantiation branch of unpack.

    The original report claimed export-data dependency loading triggers the race while full source loading appeared race-clean. At whole-tree scale that second claim turns out to be wrong. On the same repository, with the loader switched to full source mode:

    Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
        packages.NeedImports | packages.NeedDeps | packages.NeedTypes |
        packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedTypesSizes,

    the same (*Named).unpack / (*Checker).isComplete race fired during a -race run of the full test suite (~10 min of load-heavy testing to reproduce, vs ~17s in export-data mode). Nothing is shared across packages.Load calls in this setup, so the two racing checkers are workers of a single Load (x/tools/go/packages type-checks up to GOMAXPROCS packages concurrently via its cpuLimit semaphore).

    In this configuration the write side is the instantiated type branch, not the lazy-loader branch:

    • write: go/types/named.go:244 — n.fromRHS = n.expandRHS() inside the n.inst != nil branch of unpack, holding n.mu
    • read: go/types/cycles.go:122 — rhs = t.fromRHS in isComplete (case *Named:), with no lock or atomic

    Since instantiation expansion stays lazy after the declaring package finishes type-checking, two packages that both depend on a package containing a generic instantiation can race on its first Underlying() / isComplete touch.

    So export-data loading is just the fastest trigger; the unsynchronized fromRHS read in isComplete appears reachable from any concurrent multi-package checking that shares a dependency with unexpanded instantiations.

    full race trace (go1.27.0, x/tools v0.48.0, linux/amd64)
    ==================
    WARNING: DATA RACE
    Write at 0x00c200cc1030 by goroutine 154908:
      go/types.(*Named).unpack()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/named.go:244 +0x370
      go/types.(*Named).Underlying()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/named.go:602 +0x2e
      go/types.all()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/under.go:26 +0xe3
      go/types.typeset.func1()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/under.go:37 +0x1b8
      go/types.commonUnder()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/under.go:81 +0x60
      go/types.rangeKeyVal()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/range.go:210 +0x65
      go/types.(*Checker).rangeStmt()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/range.go:68 +0x1b4
      go/types.(*Checker).stmt()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/stmt.go:864 +0x235b
      go/types.(*Checker).stmtList()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/stmt.go:125 +0xda
      go/types.(*Checker).funcBody()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/stmt.go:42 +0x564
      go/types.(*Checker).funcDecl.func1()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/decl.go:790 +0xb1
      go/types.(*Checker).processDelayed()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/check.go:512 +0x404
      go/types.(*Checker).checkFiles()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/check.go:454 +0x593
      go/types.(*Checker).Files()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/check.go:422 +0x129
      golang.org/x/tools/go/packages.(*loader).loadPackage()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:1273 +0x22e7
      golang.org/x/tools/go/packages.(*loader).refine.func2.1()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:956 +0x45
      golang.org/x/sync/errgroup.(*Group).Go.func1()
          /home/buroa/.go/pkg/mod/golang.org/x/sync@v0.22.0/errgroup/errgroup.go:93 +0x86
    
    Previous read at 0x00c200cc1030 by goroutine 154911:
      go/types.(*Checker).isComplete()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/cycles.go:122 +0x98
      go/types.(*Checker).callExpr()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/call.go:331 +0x1244
      go/types.(*Checker).exprInternal()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/expr.go:1124 +0xcda
      go/types.(*Checker).rawExpr()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/expr.go:985 +0x2b5
      go/types.(*Checker).expr()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/expr.go:1267 +0x5b
      go/types.(*Checker).rangeStmt()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/range.go:38 +0x10a
      go/types.(*Checker).stmt()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/stmt.go:864 +0x235b
      go/types.(*Checker).stmtList()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/stmt.go:125 +0xda
      go/types.(*Checker).funcBody()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/stmt.go:42 +0x564
      go/types.(*Checker).funcDecl.func1()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/decl.go:790 +0xb1
      go/types.(*Checker).processDelayed()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/check.go:512 +0x404
      go/types.(*Checker).checkFiles()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/check.go:454 +0x593
      go/types.(*Checker).Files()
          /home/buroa/.local/share/mise/installs/go/1.27.0/src/go/types/check.go:422 +0x129
      golang.org/x/tools/go/packages.(*loader).loadPackage()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:1273 +0x22e7
      golang.org/x/tools/go/packages.(*loader).refine.func2.1()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:956 +0x45
      golang.org/x/sync/errgroup.(*Group).Go.func1()
          /home/buroa/.go/pkg/mod/golang.org/x/sync@v0.22.0/errgroup/errgroup.go:93 +0x86
    
    Goroutine 154908 (running) created at:
      golang.org/x/sync/errgroup.(*Group).Go()
          /home/buroa/.go/pkg/mod/golang.org/x/sync@v0.22.0/errgroup/errgroup.go:78 +0x11c
      golang.org/x/tools/go/packages.(*loader).refine.func2()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:954 +0x124
      golang.org/x/tools/go/packages.(*loader).refine.func2.1()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:962 +0xd8
      golang.org/x/sync/errgroup.(*Group).Go.func1()
          /home/buroa/.go/pkg/mod/golang.org/x/sync@v0.22.0/errgroup/errgroup.go:93 +0x86
    
    Goroutine 154911 (running) created at:
      golang.org/x/sync/errgroup.(*Group).Go()
          /home/buroa/.go/pkg/mod/golang.org/x/sync@v0.22.0/errgroup/errgroup.go:78 +0x11c
      golang.org/x/tools/go/packages.(*loader).refine.func2()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:954 +0x124
      golang.org/x/tools/go/packages.(*loader).refine.func2.1()
          /home/buroa/.go/pkg/mod/golang.org/x/tools@v0.48.0/go/packages/packages.go:962 +0xd8
      golang.org/x/sync/errgroup.(*Group).Go.func1()
          /home/buroa/.go/pkg/mod/golang.org/x/sync@v0.22.0/errgroup/errgroup.go:93 +0x86
    ==================
    
  2. adonovan commented on Aug 26, 2026

    @adonovan
    Member
  3. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Aug 28, 2026
  4. gopherbot commented on Sep 29, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/841585 mentions this issue: go/types, types2: fix data race in isComplete on imported types

  5. jmrplens commented on Sep 30, 2026

    @jmrplens

    I hit the same report ((*Checker).isComplete at cycles.go:122 against (*Named).unpack at named.go:244, both under go/packages workers) in a go test -race gate with go1.27.1 and x/tools v0.50.0, on a test that loads a handful of packages with Tests: true and without NeedDeps. Some data that may help narrow it:

    1. It reproduces without x/tools, reliably. The program below imports strings once with go/importer, then type-checks eight tiny packages concurrently against that shared *types.Package, which is the model go/packages uses for export-data dependencies. Each package ranges over strings.SplitSeq, whose result type iter.Seq[string] is an instance the importer leaves unexpanded.

    main.go
    package main
    
    import (
    	"fmt"
    	"go/ast"
    	"go/importer"
    	"go/parser"
    	"go/token"
    	"go/types"
    	"os"
    	"sync"
    )
    
    const workers = 8
    
    type sharedImporter map[string]*types.Package
    
    func (s sharedImporter) Import(path string) (*types.Package, error) {
    	if pkg, ok := s[path]; ok {
    		return pkg, nil
    	}
    	return nil, fmt.Errorf("not preloaded: %s", path)
    }
    
    func main() {
    	for round := range 5 {
    		fset := token.NewFileSet()
    		// Imported once, before any checker starts; afterwards every access is a
    		// read of a completed package.
    		strs, err := importer.ForCompiler(fset, "gc", nil).Import("strings")
    		if err != nil {
    			fmt.Fprintln(os.Stderr, err)
    			os.Exit(1)
    		}
    		imp := sharedImporter{"strings": strs}
    
    		files := make([]*ast.File, workers)
    		for i := range workers {
    			src := fmt.Sprintf(`package p%d
    
    import "strings"
    
    func F(s string) (n int) {
    	for range strings.SplitSeq(s, ",") {
    		n++
    	}
    	return n
    }
    `, i)
    			files[i], err = parser.ParseFile(fset, fmt.Sprintf("p%d.go", i), src, 0)
    			if err != nil {
    				fmt.Fprintln(os.Stderr, err)
    				os.Exit(1)
    			}
    		}
    
    		var wg sync.WaitGroup
    		for i := range workers {
    			wg.Go(func() {
    				conf := types.Config{Importer: imp}
    				if _, err := conf.Check(fmt.Sprintf("p%d", i), fset, []*ast.File{files[i]}, nil); err != nil {
    					fmt.Fprintln(os.Stderr, err)
    				}
    			})
    		}
    		wg.Wait()
    		fmt.Println("round", round, "done")
    	}
    }

    go run -race ., linux/amd64, GOMAXPROCS=5, each run a fresh process:

    Toolchain Runs with a race report Read side
    go1.27.1 30 of 30 (*Checker).isComplete, cycles.go:122, from callExpr
    go1.26.8 10 of 30 (*Checker).finiteSize, cycles.go:127, from pendingType
    go1.25.14 0 of 30 none

    The write side is in every case the instance branch of unpack shown in the trace above (named.go:250 in 1.26.8), reached from rangeKeyVal through commonUnder.

    2. go1.26 is affected too, through a different read. The unsynchronized read of another package's Named.fromRHS arrived with the value-observance check of CL 726580 (1.26, finiteSize via pendingType) and was carried into isComplete by CL 734980 (1.27). go1.25 has neither read and reports nothing.

    3. The read is the odd one out. hasVarSize in builtins.go reads the same field and says "Careful, we're inspecting t.fromRHS, so we need to unpack first." isComplete does not unpack. Named.cleanup deliberately leaves instances unexpanded ("they will lazily expand it as needed"), and the comment at the top of named.go says that lazy construction may happen concurrently after type checking or importing has finished, so an instance reachable from an imported package's API is exactly the case the mutex and atomic state exist for. fromRHS is an interface, two words, so beyond the report a torn read is at least possible in principle.

    4. A fix is up as CL 841585 (#81871). isComplete returns true for a *Named that no Checker owns (t.check == nil) without reading its fromRHS, the ownership test CL 837605 uses in unalias. Such a type was created by an importer, through the API, or by a type-checking pass that has finished, so the walk from it cannot lead back to an object on this Checker's path and always ended in true. The CL message covers the alternatives: unpacking such a type first also removes the race but expands it only to reach that answer, and unpacking unconditionally breaks TestFixedbugs/issue57522.go ("nil typ" in subst).

    It also covers the source-mode case in the update above. go/packages starts a package's Checker only after all its imports are loaded, which in source mode means their Checkers have returned, and a Checker's cleanup clears check on every instance it created without expanding the ones still unexpanded; an instance created later while expanding one of those gets a nil check as well. So every instance a dependent reaches in a dependency is unowned. An instance written as a type in the source (func F() Seq[string]) is expanded before Check returns and does not race; one created by substitution (var F = Of[string] with func Of[T any]() Seq[T], or one in the signature of a method that a dependent expands) stays unexpanded and does.

    The CL's test type-checks four packages concurrently against a shared package built each way, by the importer and from source, as two subtests. Run on its own under -race on a toolchain built from master, each subtest raced in at least 99 of 100 runs without the change and in none of 200 with it, in both go/types and types2, and the reproducer above went from 20 of 20 to none. As far as I can tell this is the only unguarded read of a *Named's fromRHS: the other direct reads outside named.go (the *Alias case beside it, object.go, signature.go) are of an *Alias, whose fromRHS is set when it is created or declared and never lazily.

    If this is fixed, a backport to 1.27 would help (1.26 has the same read in finiteSize, until it caches a result on the type): go/packages type-checks packages in parallel by design, so any -race test that loads several packages with it can hit this. The only workaround I know of is GOMAXPROCS=1 for the whole process, set before go/packages is initialized, since it sizes its type-checking pool from it then.

  6. zzffeggs commented on Sep 30, 2026

    @zzffeggs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions