Skip to content
Closed
328 changes: 221 additions & 107 deletions internal/build/build.go

Large diffs are not rendered by default.

62 changes: 44 additions & 18 deletions internal/build/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"

Expand All @@ -42,7 +41,12 @@ func (c *context) collectFingerprint(pkg *aPackage) error {
c.fingerprinting = make(map[string]bool)
}
if c.fingerprinting[pkg.ID] {
return fmt.Errorf("fingerprint cycle detected for %s", pkg.ID)
// Alternate packages can intentionally close a cycle in the runtime
// replacement graph after all packages have been built into SSA. A
// cycle cannot have a stable per-package cache key, so compile every
// member rather than returning an incorrect cache hit.
c.disablePackageCache(c.fingerprinting)
return nil
}
c.fingerprinting[pkg.ID] = true
defer delete(c.fingerprinting, pkg.ID)
Expand Down Expand Up @@ -70,6 +74,20 @@ func (c *context) collectFingerprint(pkg *aPackage) error {
return nil
}

func (c *context) disablePackageCache(pkgs map[string]bool) {
if c.cacheDisabled == nil {
c.cacheDisabled = make(map[string]none, len(pkgs))
}
for id := range pkgs {
c.cacheDisabled[id] = none{}
}
}

func (c *context) packageCacheDisabled(id string) bool {
_, disabled := c.cacheDisabled[id]
return disabled
}

// collectEnvInputs collects environment-related inputs.
func (c *context) collectEnvInputs(m *manifestBuilder) {
m.env.Goos = c.buildConf.Goos
Expand Down Expand Up @@ -196,21 +214,7 @@ func (c *context) collectPackageInputs(m *manifestBuilder, pkg *aPackage) error

// collectDependencyInputs adds dependency fingerprints/versions into manifest.
func (c *context) collectDependencyInputs(m *manifestBuilder, pkg *aPackage) error {
if len(pkg.Imports) == 0 {
return nil
}

deps := make([]*packages.Package, 0, len(pkg.Imports))
for _, dep := range pkg.Imports {
if dep == nil || dep.ID == pkg.ID {
continue
}
deps = append(deps, dep)
}

sort.Slice(deps, func(i, j int) bool { return deps[i].ID < deps[j].ID })

for _, dep := range deps {
for _, dep := range effectiveDependencies(pkg) {
depEntry, err := c.dependencyFingerprint(dep)
if err != nil {
return err
Expand All @@ -221,6 +225,21 @@ func (c *context) collectDependencyInputs(m *manifestBuilder, pkg *aPackage) err
return nil
}

// initializePackageBuildState initializes the mutable state used by the
// package pipeline before any scheduler is introduced. This makes ownership
// explicit and avoids lazy first-use writes becoming data races later.
func (c *context) initializePackageBuildState() {
if c.sfilesCache == nil {
c.sfilesCache = make(map[string][]string)
}
if !cacheEnabled() {
return
}
c.cacheManager = newCacheManager()
c.llvmVersion = detectLLVMVersion(c)
c.llvmVersionReady = true
}

func (c *context) dependencyFingerprint(dep *packages.Package) (depEntry, error) {
entry := depEntry{ID: dep.ID}
if v := moduleVersion(dep.Module); v != "" {
Expand Down Expand Up @@ -264,10 +283,11 @@ func moduleVersion(mod *gopackages.Module) string {

// getLLVMVersion returns the cached LLVM version or detects it.
func (c *context) getLLVMVersion() string {
if c.llvmVersion != "" {
if c.llvmVersionReady {
return c.llvmVersion
}
c.llvmVersion = detectLLVMVersion(c)
c.llvmVersionReady = true
return c.llvmVersion
}

Expand Down Expand Up @@ -326,6 +346,9 @@ func (c *context) tryLoadFromCache(pkg *aPackage) bool {
if !cacheEnabled() {
return false
}
if c.packageCacheDisabled(pkg.ID) {
return false
}

// Main packages are intentionally not written to the build cache because
// each executable's entry module is linked against the current main archive.
Expand Down Expand Up @@ -451,6 +474,9 @@ func (c *context) saveToCache(pkg *aPackage) error {
if !cacheEnabled() {
return nil
}
if c.packageCacheDisabled(pkg.ID) {
return nil
}

if pkg.Fingerprint == "" || pkg.Manifest == "" {
return nil
Expand Down
40 changes: 40 additions & 0 deletions internal/build/collect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,46 @@ func TestCollectFingerprint(t *testing.T) {
}
}

func TestCollectFingerprintAltDependencyCycleDisablesCache(t *testing.T) {
runtimePkg := &packages.Package{ID: "runtime", PkgPath: "runtime"}
osPkg := &packages.Package{ID: "runtime/internal/clite/os", PkgPath: "runtime/internal/clite/os"}
syscallPkg := &packages.Package{ID: "runtime/internal/clite/syscall", PkgPath: "runtime/internal/clite/syscall"}
runtime := &aPackage{
Package: runtimePkg,
AltPkg: &packages.Cached{Package: &packages.Package{
ID: "runtime/alt",
Imports: map[string]*packages.Package{"os": osPkg},
}},
}
os := &aPackage{Package: osPkg}
syscall := &aPackage{Package: syscallPkg}
os.Imports = map[string]*packages.Package{"syscall": syscallPkg}
syscall.Imports = map[string]*packages.Package{"runtime": runtimePkg}
ctx := &context{
conf: &packages.Config{},
buildConf: &Config{Goos: "js", Goarch: "wasm"},
crossCompile: crosscompile.Export{
LLVMTarget: "wasm32-unknown-unknown",
},
pkgByID: map[string]Package{
runtimePkg.ID: runtime,
osPkg.ID: os,
syscallPkg.ID: syscall,
},
}
if err := ctx.collectFingerprint(runtime); err != nil {
t.Fatalf("collectFingerprint: %v", err)
}
for _, pkg := range []*aPackage{runtime, os, syscall} {
if !ctx.packageCacheDisabled(pkg.ID) {
t.Fatalf("cache for %s was not disabled after fingerprint cycle", pkg.ID)
}
if pkg.Fingerprint == "" || pkg.Manifest == "" {
t.Fatalf("fingerprint state for %s was not completed", pkg.ID)
}
}
}

func TestCollectFingerprintDeterminism(t *testing.T) {
td := t.TempDir()

Expand Down
74 changes: 74 additions & 0 deletions internal/build/dependencies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package build

import (
"sort"

"github.com/goplus/llgo/internal/packages"
)

// effectiveDependencies returns the package graph that contributes code to an
// aPackage. An alternate package can add imports beyond the original package,
// so using only Package.Imports would allow stale cache entries after an alt
// dependency changes.
func effectiveDependencies(pkg *aPackage) []*packages.Package {
if pkg == nil || pkg.Package == nil {
return nil
}
deps := make(map[string]*packages.Package)
add := func(imports map[string]*packages.Package) {
for _, dep := range imports {
if dep == nil || dep.ID == pkg.ID || (pkg.AltPkg != nil && dep.ID == pkg.AltPkg.ID) {
continue
}
deps[dep.ID] = dep
}
}
add(pkg.Imports)
if pkg.AltPkg != nil {
add(pkg.AltPkg.Imports)
}
ret := make([]*packages.Package, 0, len(deps))
for _, dep := range deps {
ret = append(ret, dep)
}
sort.Slice(ret, func(i, j int) bool { return ret[i].ID < ret[j].ID })
return ret
}

// packageBuildDependencies returns the true Go import edges for scheduler
// ordering. Alternate-package imports still participate in cache fingerprints,
// but may intentionally form cycles with the runtime replacement graph after
// all packages have already been built into SSA.
func packageBuildDependencies(pkg *aPackage) []*packages.Package {
if pkg == nil || pkg.Package == nil {
return nil
}
deps := make(map[string]*packages.Package, len(pkg.Imports))
for _, dep := range pkg.Imports {
if dep != nil && dep.ID != pkg.ID {
deps[dep.ID] = dep
}
}
ret := make([]*packages.Package, 0, len(deps))
for _, dep := range deps {
ret = append(ret, dep)
}
sort.Slice(ret, func(i, j int) bool { return ret[i].ID < ret[j].ID })
return ret
}
64 changes: 64 additions & 0 deletions internal/build/dependencies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package build

import (
"reflect"
"testing"

"github.com/goplus/llgo/internal/packages"
)

func TestEffectiveDependenciesIncludesAlternateImports(t *testing.T) {
base := &packages.Package{ID: "base"}
shared := &packages.Package{ID: "shared"}
altOnly := &packages.Package{ID: "alt-only"}
alt := &packages.Package{
ID: "alt",
Imports: map[string]*packages.Package{"shared": shared, "alt-only": altOnly},
}
pkg := &aPackage{
Package: &packages.Package{ID: "pkg", Imports: map[string]*packages.Package{"base": base, "shared": shared}},
AltPkg: &packages.Cached{Package: alt},
}
deps := effectiveDependencies(pkg)
got := make([]string, len(deps))
for i, dep := range deps {
got[i] = dep.ID
}
if want := []string{"alt-only", "base", "shared"}; !reflect.DeepEqual(got, want) {
t.Fatalf("effectiveDependencies = %v, want %v", got, want)
}
}

func TestPackageBuildDependenciesExcludeAlternateImports(t *testing.T) {
base := &packages.Package{ID: "base"}
altOnly := &packages.Package{ID: "alt-only"}
alt := &packages.Package{ID: "patch/pkg", Imports: map[string]*packages.Package{"alt-only": altOnly}}
pkg := &aPackage{
Package: &packages.Package{ID: "pkg", Imports: map[string]*packages.Package{"base": base}},
AltPkg: &packages.Cached{Package: alt},
}
deps := packageBuildDependencies(pkg)
got := make([]string, len(deps))
for i, dep := range deps {
got[i] = dep.ID
}
if want := []string{"base"}; !reflect.DeepEqual(got, want) {
t.Fatalf("packageBuildDependencies = %v, want %v", got, want)
}
}
7 changes: 3 additions & 4 deletions internal/build/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,9 @@ func TestManifestBuilder_EmptySections(t *testing.T) {
m := newManifestBuilder()
content := m.Build()

// Empty sections should not be written
expected := ``
if content != expected {
t.Errorf("unexpected empty manifest:\ngot:\n%s\nwant:\n%s", content, expected)
// Empty sections should not be written.
if content != "" {
t.Errorf("unexpected empty manifest:\ngot:\n%s", content)
}

// Should still produce a valid fingerprint
Expand Down
1 change: 1 addition & 0 deletions internal/build/module_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

func TestModuleHookReceivesMainPackageModule(t *testing.T) {
conf := NewDefaultConf(ModeGen)
conf.Parallel = 2

counts := make(map[string]int)
snapshots := make(map[string]string)
Expand Down
Loading
Loading