Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 191 additions & 99 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
52 changes: 52 additions & 0 deletions internal/build/dependencies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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
}
46 changes: 46 additions & 0 deletions internal/build/dependencies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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)
}
}
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
14 changes: 13 additions & 1 deletion internal/goflags/flagfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ import (
)

var argumentListFlagNames = [...]string{
"asmflags",
"gcflags",
"gccgoflags",
"ldflags",
"p",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding "p" to argumentListFlagNames has an asymmetric side effect in flag files.

This list is used both by normalizeBuildFlags (so -p 3-p=3, which parseBuildParallel needs) and by wholeLineValueFlag in this file, which treats the entire remainder of a flags-file line as a single value. That greedy behavior is correct for genuine argument-list flags like -ldflags, but -p takes a single integer.

Consequence: a flags-file line combining -p with other flags, e.g.

-p=4 -trimpath

is collapsed into the single token -p=4 -trimpath. strconv.Atoi("4 -trimpath") then fails with -p must be a positive integer, got "4 -trimpath", and -trimpath is silently swallowed. The equivalent command-line form works because normalizeBuildFlags processes each arg independently.

Suggestion: allow -p to be normalized without adding it to the whole-line argumentListFlagNames set — e.g. a separate list for wholeLineValueFlag, or special-case -p so it only claims the single following integer. A test covering -p combined with other flags on one flags-file line would guard this.

"toolexec",
}

// wholeLineValueFlagNames is the subset whose unquoted values can themselves
// contain arbitrary flag-like words. Scalar flags such as -p still belong to
// argumentListFlagNames for normalization, but must not consume a whole line.
var wholeLineValueFlagNames = [...]string{
"asmflags",
"gcflags",
"gccgoflags",
Expand Down Expand Up @@ -62,7 +74,7 @@ func ParseFlagFile(data string) ([]string, error) {
}

func wholeLineValueFlag(line string) (flag string, ok bool) {
for _, name := range argumentListFlagNames {
for _, name := range wholeLineValueFlagNames {
for _, prefix := range []string{"-" + name + "=", "--" + name + "="} {
value, found := strings.CutPrefix(line, prefix)
if !found {
Expand Down
11 changes: 11 additions & 0 deletions internal/goflags/flagfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,14 @@ func TestParseFlagFileErrors(t *testing.T) {
}
}
}

func TestParseFlagFileKeepsScalarParallelFlagSeparate(t *testing.T) {
got, err := ParseFlagFile("-p=4 -trimpath -tags=fast\n")
if err != nil {
t.Fatal(err)
}
want := []string{"-p=4", "-trimpath", "-tags=fast"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseFlagFile() = %#v, want %#v", got, want)
}
}
33 changes: 32 additions & 1 deletion internal/goflags/gobuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@

package goflags

import "github.com/goplus/llgo/internal/build"
import (
"fmt"
"strconv"
"strings"

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

// ApplyBuildFlags validates and appends normalized Go build flags, and maps
// the supported compiler and linker semantics into typed build configuration.
Expand All @@ -34,12 +40,37 @@ func ApplyBuildFlags(conf *build.Config, args []string) error {
if err != nil {
return err
}
parallel, parallelSet, err := parseBuildParallel(all)
if err != nil {
return err
}
next := *conf
next.GoBuildFlags = all
applyFrontendGCFlags(&next)
if parallelSet {
next.Parallel = parallel
}
if linkFlags.Present {
next.LinkOptions = linkFlags.Options
}
*conf = next
return nil
}

// parseBuildParallel extracts Go's -p build concurrency flag after it has
// been normalized. Keeping it in GoBuildFlags still lets go/packages apply the
// same setting while Config.Parallel controls LLGo's own build stages.
func parseBuildParallel(flags []string) (parallel int, present bool, err error) {
for _, flag := range flags {
value, ok := strings.CutPrefix(flag, "-p=")
if !ok {
continue
}
parallel, err = strconv.Atoi(value)
if err != nil || parallel <= 0 {
return 0, false, fmt.Errorf("-p must be a positive integer, got %q", value)
}
present = true
}
return parallel, present, nil
}
Loading
Loading