From 2ec8470ef4f637494cad2af8aa478fae0a1867f7 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 17:20:27 +0800 Subject: [PATCH 1/5] test: share builds before parallel runs --- cmd/internal/test/test.go | 219 ++------------------------------- cmd/internal/test/test_test.go | 152 ++--------------------- internal/build/build.go | 36 ++++-- internal/build/build_test.go | 27 +++- internal/build/run.go | 143 ++++++++++++++++++--- internal/build/run_test.go | 130 +++++++++++++++++++ 6 files changed, 325 insertions(+), 382 deletions(-) create mode 100644 internal/build/run_test.go diff --git a/cmd/internal/test/test.go b/cmd/internal/test/test.go index d7a8785dc5..1f023ba0a7 100644 --- a/cmd/internal/test/test.go +++ b/cmd/internal/test/test.go @@ -3,15 +3,9 @@ package test import ( - "bytes" "fmt" - "io" "os" - "os/exec" - "runtime" "strconv" - "strings" - "sync" "github.com/goplus/llgo/cmd/internal/base" "github.com/goplus/llgo/cmd/internal/flags" @@ -27,8 +21,6 @@ var Cmd = &base.Command{ var goBuildFlags *base.PassArgs -const parallelWorkerEnv = "LLGO_TEST_PARALLEL_WORKER" - func init() { Cmd.Run = runCmd goBuildFlags = flags.CaptureGoBuildFlags(Cmd) @@ -73,56 +65,11 @@ func runCmd(cmd *base.Command, args []string) { // Build test binary arguments from flags conf.RunArgs = buildTestArgs(testBinaryArgs) + conf.TestFailFast = flags.TestFailfast + conf.TestJSON = flags.TestJSON + conf.TestRunSequential = testRunsMustBeSequential() pkgArgs := cmd.Flag.Args() - parallelism := effectiveParallelism(conf.BuildParallelism) - if canRunPackagesInParallel(conf, pkgArgs, parallelism) { - pkgs, err := listTestPackages(conf, pkgArgs) - if err != nil { - fmt.Fprintln(os.Stderr, err) - mockable.Exit(1) - } - if len(pkgs) > 1 { - executable, err := os.Executable() - if err != nil { - fmt.Fprintln(os.Stderr, err) - mockable.Exit(1) - } - // flag.FlagSet stops at the first non-flag, so pkgArgs is a - // contiguous suffix of llgoArgs. - flagArgs := llgoArgs[:len(llgoArgs)-len(pkgArgs)] - var outputMu sync.Mutex - result := runTestPackages(pkgs, parallelism, flags.TestFailfast, func(pkg string) error { - childArgs := buildParallelChildArgs(flagArgs, pkg, testBinaryArgs) - child := exec.Command(executable, childArgs...) - child.Env = append(os.Environ(), parallelWorkerEnv+"=1") - var output bytes.Buffer - child.Stdout = &output - child.Stderr = &output - err := child.Run() - - // Flush one completed package at a time so parallel child - // output cannot interleave. - outputMu.Lock() - if err != nil { - if _, ok := err.(*exec.ExitError); !ok { - fmt.Fprintf(os.Stderr, "failed to run test package %s: %v\n", pkg, err) - } - } - reportTestPackageResult(os.Stdout, os.Stderr, pkg, output.Bytes(), err, flags.TestJSON) - outputMu.Unlock() - return err - }) - if result.skipped != 0 { - fmt.Fprintf(os.Stderr, "FAIL\t%d package(s) skipped by -failfast\n", result.skipped) - } - if result.failed { - mockable.Exit(1) - } - return - } - } - _, err := build.Do(pkgArgs, conf) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -130,159 +77,17 @@ func runCmd(cmd *base.Command, args []string) { } } -func canRunPackagesInParallel(conf *build.Config, pkgArgs []string, parallelism int) bool { - if os.Getenv(parallelWorkerEnv) != "" || conf.Target != "" || conf.CompileOnly || conf.OutFile != "" { - return false - } - if parallelism == 1 { - return false - } - for _, arg := range pkgArgs { - if strings.HasSuffix(arg, ".go") { - return false - } - } +func testRunsMustBeSequential() bool { // These flags name process-wide output files. Until LLGo merges or // disambiguates them like cmd/go, keep the existing sequential behavior. - return flags.TestCoverProfile == "" && - flags.TestCPUProfile == "" && - flags.TestMemProfile == "" && - flags.TestBlockProfile == "" && - flags.TestMutexProfile == "" && - flags.TestTrace == "" && - flags.TestTestLogFile == "" && - flags.TestFuzz == "" -} - -func effectiveParallelism(parallelism int) int { - if parallelism == 0 { - parallelism = runtime.GOMAXPROCS(0) - } - if parallelism < 1 { - return 1 - } - return parallelism -} - -func listTestPackages(conf *build.Config, patterns []string) ([]string, error) { - if len(patterns) == 0 { - patterns = []string{"."} - } - tags := build.DefaultBuildTags(conf.Goarch, conf.Target) - if conf.Tags != "" { - tags += "," + conf.Tags - } - args := make([]string, 0, 4+len(conf.GoBuildFlags)+len(patterns)) - args = append(args, "list", "-tags="+tags) - args = append(args, conf.GoBuildFlags...) - args = append(args, "--") - args = append(args, patterns...) - - list := exec.Command("go", args...) - list.Env = append(os.Environ(), "GOOS="+conf.Goos, "GOARCH="+conf.Goarch) - var stderr bytes.Buffer - list.Stderr = &stderr - output, err := list.Output() - if err != nil { - if message := strings.TrimSpace(stderr.String()); message != "" { - return nil, fmt.Errorf("%s", message) - } - return nil, err - } - - lines := strings.Fields(string(output)) - pkgs := make([]string, 0, len(lines)) - seen := make(map[string]bool, len(lines)) - for _, pkg := range lines { - if !seen[pkg] { - seen[pkg] = true - pkgs = append(pkgs, pkg) - } - } - return pkgs, nil -} - -func buildParallelChildArgs(flagArgs []string, pkg string, testBinaryArgs []string) []string { - args := make([]string, 0, 3+len(flagArgs)+len(testBinaryArgs)) - args = append(args, "test") - for i := 0; i < len(flagArgs); i++ { - arg := flagArgs[i] - switch { - case arg == "-p" || arg == "--p": - i++ // The successfully parsed flag always has a following value. - case strings.HasPrefix(arg, "-p=") || strings.HasPrefix(arg, "--p="): - case arg == "--": - // The package pattern has already been resolved by go list, so - // the parent's flag terminator is no longer needed. - default: - args = append(args, arg) - } - } - // The parent owns package-level fan-out. Keep each worker's go/packages - // loading serial to avoid multiplying -p across child processes. - args = append(args, "-p=1") - args = append(args, pkg) - if len(testBinaryArgs) != 0 { - args = append(args, "-args") - args = append(args, testBinaryArgs...) - } - return args -} - -func reportTestPackageResult(stdout, stderr io.Writer, pkg string, output []byte, err error, json bool) { - if len(output) != 0 { - _, _ = stdout.Write(output) - if output[len(output)-1] != '\n' { - fmt.Fprintln(stdout) - } - } - if err != nil { - fmt.Fprintf(stderr, "FAIL\t%s\n", pkg) - } else if !json { - fmt.Fprintf(stdout, "ok \t%s\n", pkg) - } -} - -type testRunResult struct { - failed bool - skipped int -} - -func runTestPackages(pkgs []string, parallelism int, failFast bool, run func(string) error) testRunResult { - if parallelism < 1 { - parallelism = 1 - } - if parallelism > len(pkgs) { - parallelism = len(pkgs) - } - results := make(chan error, parallelism) - start := func(pkg string) { - go func() { - results <- run(pkg) - }() - } - - next := 0 - running := 0 - for next < len(pkgs) && running < parallelism { - start(pkgs[next]) - next++ - running++ - } - var result testRunResult - for running != 0 { - if err := <-results; err != nil { - result.failed = true - } - running-- - if next < len(pkgs) && !(failFast && result.failed) { - start(pkgs[next]) - next++ - running++ - } - } - result.skipped = len(pkgs) - next - return result + return flags.TestCoverProfile != "" || + flags.TestCPUProfile != "" || + flags.TestMemProfile != "" || + flags.TestBlockProfile != "" || + flags.TestMutexProfile != "" || + flags.TestTrace != "" || + flags.TestTestLogFile != "" || + flags.TestFuzz != "" } // splitArgsAt splits args at the separator flag (e.g., "-args") diff --git a/cmd/internal/test/test_test.go b/cmd/internal/test/test_test.go index 760944ab74..d7b47ea7cc 100644 --- a/cmd/internal/test/test_test.go +++ b/cmd/internal/test/test_test.go @@ -3,15 +3,10 @@ package test import ( - "bytes" - "errors" "reflect" - "strings" - "sync/atomic" "testing" "github.com/goplus/llgo/cmd/internal/flags" - "github.com/goplus/llgo/internal/build" ) func TestBuildFlagsWiring(t *testing.T) { @@ -118,152 +113,21 @@ func TestSplitArgsAt(t *testing.T) { } } -func TestBuildParallelChildArgs(t *testing.T) { - got := buildParallelChildArgs( - []string{"-p", "3", "--p=4", "-run=TestOne", "--"}, - "example.com/p", - []string{"-custom", "value"}, - ) - want := []string{ - "test", "-run=TestOne", "-p=1", "example.com/p", - "-args", "-custom", "value", - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("buildParallelChildArgs() = %v, want %v", got, want) - } -} - -func TestCanRunPackagesInParallel(t *testing.T) { +func TestTestRunsMustBeSequential(t *testing.T) { resetTestFlags() - t.Setenv(parallelWorkerEnv, "") - conf := build.NewDefaultConf(build.ModeTest) - conf.BuildParallelism = 2 - if !canRunPackagesInParallel(conf, []string{"./..."}, 2) { - t.Fatal("ordinary package pattern cannot run in parallel") + if testRunsMustBeSequential() { + t.Fatal("ordinary tests must not be forced to run sequentially") } flags.TestCoverProfile = "cover.out" - if canRunPackagesInParallel(conf, []string{"./..."}, 2) { - t.Fatal("shared coverage profile can run in parallel") + if !testRunsMustBeSequential() { + t.Fatal("shared coverage profile must force sequential test execution") } flags.TestCoverProfile = "" - if canRunPackagesInParallel(conf, []string{"one_test.go"}, 2) { - t.Fatal("Go file arguments can run in parallel") - } - conf.CompileOnly = true - if canRunPackagesInParallel(conf, []string{"./..."}, 2) { - t.Fatal("-c can run in parallel") - } -} - -func TestListTestPackages(t *testing.T) { - conf := build.NewDefaultConf(build.ModeTest) - conf.BuildParallelism = 2 - pkg := "github.com/goplus/llgo/internal/goflags" - got, err := listTestPackages(conf, []string{pkg, pkg}) - if err != nil { - t.Fatal(err) - } - if want := []string{pkg}; !reflect.DeepEqual(got, want) { - t.Fatalf("listTestPackages() = %v, want %v", got, want) - } - - _, err = listTestPackages(conf, []string{"-definitely-not-a-package"}) - if err == nil { - t.Fatal("leading-dash package pattern unexpectedly succeeded") - } - if strings.Contains(err.Error(), "flag provided but not defined") { - t.Fatalf("leading-dash package pattern was parsed as a flag: %v", err) - } -} - -func TestReportTestPackageResult(t *testing.T) { - var stdout, stderr bytes.Buffer - reportTestPackageResult(&stdout, &stderr, "example.com/pass", []byte("PASS"), nil, false) - if got, want := stdout.String(), "PASS\nok \texample.com/pass\n"; got != want { - t.Fatalf("success stdout = %q, want %q", got, want) - } - if stderr.Len() != 0 { - t.Fatalf("success stderr = %q", stderr.String()) - } - - stdout.Reset() - stderr.Reset() - reportTestPackageResult(&stdout, &stderr, "example.com/fail", []byte("failure\n"), errors.New("failed"), false) - if got, want := stdout.String(), "failure\n"; got != want { - t.Fatalf("failure stdout = %q, want %q", got, want) - } - if got, want := stderr.String(), "FAIL\texample.com/fail\n"; got != want { - t.Fatalf("failure stderr = %q, want %q", got, want) - } - - stdout.Reset() - stderr.Reset() - reportTestPackageResult(&stdout, &stderr, "example.com/json", []byte("{\"Action\":\"pass\"}\n"), nil, true) - if got, want := stdout.String(), "{\"Action\":\"pass\"}\n"; got != want { - t.Fatalf("JSON stdout = %q, want %q", got, want) - } - if stderr.Len() != 0 { - t.Fatalf("JSON stderr = %q", stderr.String()) - } -} - -func TestRunTestPackagesLimitAndFailure(t *testing.T) { - started := make(chan struct{}, 4) - release := make(chan struct{}) - result := make(chan testRunResult) - var active atomic.Int32 - var maximum atomic.Int32 - go func() { - result <- runTestPackages([]string{"a", "b", "c", "d"}, 2, false, func(pkg string) error { - now := active.Add(1) - for { - old := maximum.Load() - if now <= old || maximum.CompareAndSwap(old, now) { - break - } - } - started <- struct{}{} - <-release - active.Add(-1) - if pkg == "d" { - return errors.New("failed") - } - return nil - }) - }() - - <-started - <-started - select { - case <-started: - t.Fatal("more than two packages started concurrently") - default: - } - close(release) - if got := <-result; !got.failed { - t.Fatal("runTestPackages reported success after a package failed") - } - if got := maximum.Load(); got != 2 { - t.Fatalf("maximum concurrency = %d, want 2", got) - } -} - -func TestRunTestPackagesFailFast(t *testing.T) { - var runs atomic.Int32 - result := runTestPackages([]string{"a", "b", "c"}, 1, true, func(string) error { - runs.Add(1) - return errors.New("failed") - }) - if !result.failed { - t.Fatal("runTestPackages reported success") - } - if result.skipped != 2 { - t.Fatalf("skipped %d packages, want 2", result.skipped) - } - if got := runs.Load(); got != 1 { - t.Fatalf("ran %d packages after the first failure, want 1", got) + flags.TestFuzz = "FuzzOne" + if !testRunsMustBeSequential() { + t.Fatal("fuzzing must force sequential test execution") } } diff --git a/internal/build/build.go b/internal/build/build.go index c2f80c4136..b0343b4a33 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -171,10 +171,16 @@ type Config struct { // go/packages. Callers use internal/goflags to parse supported compiler and // linker semantics into typed Config fields before calling Do. GoBuildFlags []string - // BuildParallelism is the package-level concurrency requested by Go's -p - // build flag for llgo test. Zero uses the Go default, GOMAXPROCS. + // BuildParallelism is the maximum number of already-built native test + // binaries to run concurrently. It is populated from Go's -p build flag; + // zero uses the Go default, GOMAXPROCS. BuildParallelism int - LinkOptions LinkOptions + // TestRunSequential disables concurrent test binary execution for test + // flags that write to a shared process-wide output file. + TestRunSequential bool + TestFailFast bool + TestJSON bool + LinkOptions LinkOptions // OmitDWARFByDefault controls linked builds only when -w was not // explicitly specified. Explicit -w and -w=false always win. OmitDWARFByDefault bool @@ -592,6 +598,7 @@ func Do(args []string, conf *Config) ([]Package, error) { return nil, fmt.Errorf("initial package not found") } + var testPrograms []testProgram for _, pkg := range initial { if needLink(pkg, mode) { name := path.Base(pkg.PkgPath) @@ -653,6 +660,16 @@ func Do(args []string, conf *Config) ([]Package, error) { } case ModeRun, ModeTest, ModeCmpTest: + if mode == ModeTest && conf.Target == "" { + if !conf.CompileOnly { + testPrograms = append(testPrograms, testProgram{ + app: outFmts.Out, + pkgDir: pkg.Dir, + pkgName: strings.TrimSuffix(pkg.PkgPath, ".test"), + }) + } + break + } if conf.Target == "" { err = runNative(ctx, outFmts.Out, pkg.Dir, pkg.PkgPath, conf, mode) } else if conf.Emulator { @@ -678,6 +695,14 @@ func Do(args []string, conf *Config) ([]Package, error) { } } + if len(testPrograms) != 0 { + result := runNativeTestPrograms(testPrograms, conf, os.Stdout, os.Stderr) + ctx.testFail = result.failed + if result.skipped != 0 { + fmt.Fprintf(os.Stderr, "FAIL\t%d package(s) skipped by -failfast\n", result.skipped) + } + } + if mode == ModeTest && ctx.testFail { mockable.Exit(1) } @@ -720,11 +745,6 @@ func applyBuildModeCompileFlags(mode BuildMode, export *crosscompile.Export) { } } -// DefaultBuildTags returns the build tags LLGo always enables for a target. -func DefaultBuildTags(goarch, target string) string { - return defaultBuildTags(goarch, target) -} - func defaultBuildTags(goarch, target string) string { tags := "llgo,math_big_pure_go,purego" // Raw GOOS/GOARCH wasm builds do not have a target configuration that diff --git a/internal/build/build_test.go b/internal/build/build_test.go index cf3054c1e9..8e5e24e175 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -286,13 +286,32 @@ func TestExtest(t *testing.T) { outputChan <- data.String() }() - mockRun([]string{"../../cl/_testgo/runextest/..."}, &Config{Mode: ModeTest}) + var testingBuilds int + conf := &Config{Mode: ModeTest, BuildParallelism: 2} + conf.ModuleHook = func(pkg Package) { + if pkg.PkgPath == "testing" { + testingBuilds++ + } + } + mockRun([]string{"../../cl/_testgo/runextest/..."}, conf) w.Close() got := <-outputChan - expected := "PASS\nPASS\nPASS\nPASS\n" - if got != expected { - t.Errorf("Expected output %q, but got %q", expected, got) + if testingBuilds != 1 { + t.Errorf("testing package builds = %d, want 1 shared build", testingBuilds) + } + if count := strings.Count(got, "PASS\n"); count != 4 { + t.Errorf("PASS count = %d, want 4; output:\n%s", count, got) + } + for _, pkg := range []string{ + "github.com/goplus/llgo/cl/_testgo/runextest", + "github.com/goplus/llgo/cl/_testgo/runextest/bar", + "github.com/goplus/llgo/cl/_testgo/runextest/bar/barinner", + "github.com/goplus/llgo/cl/_testgo/runextest/foo", + } { + if !strings.Contains(got, "ok \t"+pkg+"\n") { + t.Errorf("output does not contain result for %s:\n%s", pkg, got) + } } } diff --git a/internal/build/run.go b/internal/build/run.go index b5a99b31e5..5f0ee4f7ff 100644 --- a/internal/build/run.go +++ b/internal/build/run.go @@ -17,15 +17,136 @@ package build import ( + "bytes" "fmt" + "io" "os" "os/exec" + "runtime" "strings" "github.com/goplus/llgo/internal/mockable" "github.com/goplus/llgo/internal/shellparse" ) +type testProgram struct { + app string + pkgDir string + pkgName string +} + +type testRunResult struct { + failed bool + skipped int +} + +type testProgramResult struct { + program testProgram + output []byte + err error +} + +func runNativeTest(program testProgram, conf *Config, stdout, stderr io.Writer) error { + if conf.PrintCommands { + fmt.Fprintf(stderr, "%s %s\n", program.app, strings.Join(conf.RunArgs, " ")) + } + cmd := exec.Command(program.app, conf.RunArgs...) + cmd.Dir = program.pkgDir + cmd.Stdout = stdout + cmd.Stderr = stderr + err := cmd.Run() + if err == nil { + return nil + } + if exitErr, ok := err.(*exec.ExitError); ok { + fmt.Fprintf(stderr, "%s: exit code %d\n", program.app, exitErr.ExitCode()) + } else { + fmt.Fprintf(stderr, "failed to run test %s: %v\n", program.app, err) + } + return err +} + +func runNativeTestPrograms(programs []testProgram, conf *Config, stdout, stderr io.Writer) testRunResult { + parallelism := conf.BuildParallelism + if conf.TestRunSequential { + parallelism = 1 + } + return runTestPrograms(programs, parallelism, conf.TestFailFast, conf.TestJSON, stdout, stderr, + func(program testProgram, output io.Writer) error { + return runNativeTest(program, conf, output, output) + }) +} + +func reportTestProgramResult(stdout, stderr io.Writer, result testProgramResult, json bool) { + if len(result.output) != 0 { + _, _ = stdout.Write(result.output) + if result.output[len(result.output)-1] != '\n' { + fmt.Fprintln(stdout) + } + } + if result.err != nil { + fmt.Fprintf(stderr, "FAIL\t%s\n", result.program.pkgName) + } else if !json { + fmt.Fprintf(stdout, "ok \t%s\n", result.program.pkgName) + } +} + +func runTestPrograms( + programs []testProgram, + parallelism int, + failFast bool, + json bool, + stdout, stderr io.Writer, + run func(testProgram, io.Writer) error, +) testRunResult { + if len(programs) == 0 { + return testRunResult{} + } + if parallelism == 0 { + parallelism = runtime.GOMAXPROCS(0) + } + if parallelism < 1 { + parallelism = 1 + } + if parallelism > len(programs) { + parallelism = len(programs) + } + + results := make(chan testProgramResult, parallelism) + start := func(program testProgram) { + go func() { + var output bytes.Buffer + err := run(program, &output) + results <- testProgramResult{program: program, output: output.Bytes(), err: err} + }() + } + + next := 0 + running := 0 + for next < len(programs) && running < parallelism { + start(programs[next]) + next++ + running++ + } + + var result testRunResult + for running != 0 { + completed := <-results + reportTestProgramResult(stdout, stderr, completed, json) + if completed.err != nil { + result.failed = true + } + running-- + if next < len(programs) && !(failFast && result.failed) { + start(programs[next]) + next++ + running++ + } + } + result.skipped = len(programs) - next + return result +} + func runNative(ctx *context, app, pkgDir, pkgName string, conf *Config, mode Mode) error { // Skip execution if CompileOnly is true if conf.CompileOnly { @@ -71,25 +192,9 @@ func runNative(ctx *context, app, pkgDir, pkgName string, conf *Config, mode Mod mockable.Exit(s.ExitCode()) } case ModeTest: - if conf.PrintCommands { - fmt.Fprintf(os.Stderr, "%s %s\n", app, strings.Join(conf.RunArgs, " ")) - } - cmd := exec.Command(app, conf.RunArgs...) - cmd.Dir = pkgDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - fmt.Fprintf(os.Stderr, "%s: exit code %d\n", app, exitErr.ExitCode()) - if !ctx.testFail { - ctx.testFail = true - } - } else { - fmt.Fprintf(os.Stderr, "failed to run test %s: %v\n", app, err) - if !ctx.testFail { - ctx.testFail = true - } - } + program := testProgram{app: app, pkgDir: pkgDir, pkgName: pkgName} + if err := runNativeTest(program, conf, os.Stdout, os.Stderr); err != nil { + ctx.testFail = true } case ModeCmpTest: cmpTest(pkgDir, pkgName, app, conf.GenExpect, conf.RunArgs) diff --git a/internal/build/run_test.go b/internal/build/run_test.go new file mode 100644 index 0000000000..8890128176 --- /dev/null +++ b/internal/build/run_test.go @@ -0,0 +1,130 @@ +//go:build !llgo + +/* + * 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 ( + "bytes" + "errors" + "fmt" + "io" + "strings" + "sync/atomic" + "testing" +) + +func testPrograms(names ...string) []testProgram { + programs := make([]testProgram, len(names)) + for i, name := range names { + programs[i] = testProgram{app: name + ".test", pkgName: name} + } + return programs +} + +func TestRunTestProgramsLimitAndFailure(t *testing.T) { + started := make(chan struct{}, 4) + release := make(chan struct{}) + done := make(chan testRunResult) + var stdout, stderr bytes.Buffer + var active atomic.Int32 + var maximum atomic.Int32 + + go func() { + done <- runTestPrograms(testPrograms("a", "b", "c", "d"), 2, false, false, &stdout, &stderr, + func(program testProgram, output io.Writer) error { + now := active.Add(1) + for { + old := maximum.Load() + if now <= old || maximum.CompareAndSwap(old, now) { + break + } + } + started <- struct{}{} + <-release + active.Add(-1) + fmt.Fprintln(output, program.pkgName) + if program.pkgName == "d" { + return errors.New("failed") + } + return nil + }) + }() + + <-started + <-started + select { + case <-started: + t.Fatal("more than two test programs started concurrently") + default: + } + close(release) + + result := <-done + if !result.failed { + t.Fatal("runTestPrograms reported success after a test program failed") + } + if result.skipped != 0 { + t.Fatalf("runTestPrograms skipped %d programs, want 0", result.skipped) + } + if got := maximum.Load(); got != 2 { + t.Fatalf("maximum concurrency = %d, want 2", got) + } + for _, name := range []string{"a", "b", "c", "d"} { + if !strings.Contains(stdout.String(), name+"\n") { + t.Errorf("stdout does not contain output for %s: %q", name, stdout.String()) + } + } + if got, want := stderr.String(), "FAIL\td\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestRunTestProgramsFailFast(t *testing.T) { + var runs atomic.Int32 + result := runTestPrograms(testPrograms("a", "b", "c"), 1, true, false, io.Discard, io.Discard, + func(testProgram, io.Writer) error { + runs.Add(1) + return errors.New("failed") + }) + if !result.failed { + t.Fatal("runTestPrograms reported success") + } + if result.skipped != 2 { + t.Fatalf("skipped %d test programs, want 2", result.skipped) + } + if got := runs.Load(); got != 1 { + t.Fatalf("ran %d test programs after the first failure, want 1", got) + } +} + +func TestRunTestProgramsJSONOutput(t *testing.T) { + var stdout, stderr bytes.Buffer + result := runTestPrograms(testPrograms("json"), 1, false, true, &stdout, &stderr, + func(testProgram, io.Writer) error { + return nil + }) + if result.failed || result.skipped != 0 { + t.Fatalf("runTestPrograms result = %+v", result) + } + if stdout.Len() != 0 { + t.Fatalf("JSON success output includes a plain-text package result: %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("JSON success stderr = %q", stderr.String()) + } +} From 08bc69da85312e214342d7530accd87b957e95aa Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 17:27:51 +0800 Subject: [PATCH 2/5] test: clarify sequential run reasons --- cmd/internal/test/test.go | 4 ++-- internal/build/build.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/internal/test/test.go b/cmd/internal/test/test.go index 1f023ba0a7..a614c8eb5c 100644 --- a/cmd/internal/test/test.go +++ b/cmd/internal/test/test.go @@ -78,8 +78,8 @@ func runCmd(cmd *base.Command, args []string) { } func testRunsMustBeSequential() bool { - // These flags name process-wide output files. Until LLGo merges or - // disambiguates them like cmd/go, keep the existing sequential behavior. + // These flags either name output paths shared by every test binary or, for + // fuzzing, require one active test binary. Keep their execution sequential. return flags.TestCoverProfile != "" || flags.TestCPUProfile != "" || flags.TestMemProfile != "" || diff --git a/internal/build/build.go b/internal/build/build.go index b0343b4a33..cb7e4378f0 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -175,8 +175,8 @@ type Config struct { // binaries to run concurrently. It is populated from Go's -p build flag; // zero uses the Go default, GOMAXPROCS. BuildParallelism int - // TestRunSequential disables concurrent test binary execution for test - // flags that write to a shared process-wide output file. + // TestRunSequential disables concurrent test binary execution when test + // flags share output paths or otherwise require one active binary. TestRunSequential bool TestFailFast bool TestJSON bool From c2b9cda0ab3cc18f287afa0df062c94b434a4570 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 18:48:31 +0800 Subject: [PATCH 3/5] ssa: canonicalize basic aliases in generic symbols --- cl/_testgo/tpnamed/in.go | 24 ++++++++++++------------ ssa/abi/abi.go | 9 +++++++++ ssa/abi/abi_patch_test.go | 15 ++++++++++++++- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/cl/_testgo/tpnamed/in.go b/cl/_testgo/tpnamed/in.go index a96504f1a6..1f5c9af00c 100644 --- a/cl/_testgo/tpnamed/in.go +++ b/cl/_testgo/tpnamed/in.go @@ -46,15 +46,15 @@ func WriteFile(fileName string) IO[error] { // CHECK-LABEL: define void @"{{.*}}/cl/_testgo/tpnamed.main"(){{.*}} { // CHECK-NEXT: _llgo_0: -// CHECK-NEXT: %0 = call [0 x i8] @"{{.*}}/cl/_testgo/tpnamed.RunIO{{\[\[0\]byte\]}}"(%"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]byte\]}}" { ptr @"__llgo_stub.{{.*}}/cl/_testgo/tpnamed.main$1", ptr null }) +// CHECK-NEXT: %0 = call [0 x i8] @"{{.*}}/cl/_testgo/tpnamed.RunIO{{\[\[0\]uint8\]}}"(%"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]uint8\]}}" { ptr @"__llgo_stub.{{.*}}/cl/_testgo/tpnamed.main$1", ptr null }) // CHECK-NEXT: ret void // CHECK-NEXT: } func main() { - // CHECK-LABEL: define %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" @"{{.*}}/cl/_testgo/tpnamed.main$1"() + // CHECK-LABEL: define %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" @"{{.*}}/cl/_testgo/tpnamed.main$1"() // CHECK-NEXT: _llgo_0: - // CHECK-NEXT: ret %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" { ptr @"__llgo_stub.{{.*}}/cl/_testgo/tpnamed.main$1$1", ptr null } + // CHECK-NEXT: ret %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" { ptr @"__llgo_stub.{{.*}}/cl/_testgo/tpnamed.main$1$1", ptr null } // CHECK-NEXT: } RunIO[Void](func() Future[Void] { @@ -86,19 +86,19 @@ func RunIO[T any](call IO[T]) T { // CHECK-NEXT: ret %"{{.*}}/runtime/internal/runtime.iface" %1 // CHECK-NEXT: } -// CHECK-LABEL: define linkonce %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" @"__llgo_stub.{{.*}}/cl/_testgo/tpnamed.main$1"(ptr %0){{.*}} { +// CHECK-LABEL: define linkonce %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" @"__llgo_stub.{{.*}}/cl/_testgo/tpnamed.main$1"(ptr %0){{.*}} { // CHECK-NEXT: _llgo_0: -// CHECK-NEXT: %1 = tail call %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" @"{{.*}}/cl/_testgo/tpnamed.main$1"() -// CHECK-NEXT: ret %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" %1 +// CHECK-NEXT: %1 = tail call %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" @"{{.*}}/cl/_testgo/tpnamed.main$1"() +// CHECK-NEXT: ret %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" %1 // CHECK-NEXT: } -// CHECK-LABEL: define linkonce [0 x i8] @"{{.*}}/cl/_testgo/tpnamed.RunIO{{\[\[0\]byte\]}}"(%"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]byte\]}}" %0){{.*}} { +// CHECK-LABEL: define linkonce [0 x i8] @"{{.*}}/cl/_testgo/tpnamed.RunIO{{\[\[0\]uint8\]}}"(%"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]uint8\]}}" %0){{.*}} { // CHECK-NEXT: _llgo_0: -// CHECK-NEXT: %1 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]byte\]}}" %0, 1 -// CHECK-NEXT: %2 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]byte\]}}" %0, 0 -// CHECK-NEXT: %3 = call %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" %2(ptr %1) -// CHECK-NEXT: %4 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" %3, 1 -// CHECK-NEXT: %5 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]byte\]}}" %3, 0 +// CHECK-NEXT: %1 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]uint8\]}}" %0, 1 +// CHECK-NEXT: %2 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.IO{{\[\[0\]uint8\]}}" %0, 0 +// CHECK-NEXT: %3 = call %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" %2(ptr %1) +// CHECK-NEXT: %4 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" %3, 1 +// CHECK-NEXT: %5 = extractvalue %"{{.*}}/cl/_testgo/tpnamed.Future{{\[\[0\]uint8\]}}" %3, 0 // CHECK-NEXT: %6 = icmp eq ptr %5, null // CHECK-NEXT: call void @"{{.*}}/runtime/internal/runtime.AssertNilDeref"(i1 %6) // CHECK-NEXT: %7 = call [0 x i8] %5(ptr %4) diff --git a/ssa/abi/abi.go b/ssa/abi/abi.go index f178cecb54..64e6d23875 100644 --- a/ssa/abi/abi.go +++ b/ssa/abi/abi.go @@ -245,6 +245,15 @@ func typeArgString(t types.Type) string { case *types.Alias: return typeArgString(types.Unalias(t)) case *types.Basic: + // byte and rune are aliases for uint8 and int32. go/types may cache a + // generic instance using either spelling, so ABI symbols must not + // depend on which spelling was instantiated first. + switch t.Kind() { + case types.Byte: + return types.Typ[types.Uint8].String() + case types.Rune: + return types.Typ[types.Int32].String() + } return t.String() case *types.Named: return namedLikeTypeArgString(t.Obj(), t.TypeArgs()) diff --git a/ssa/abi/abi_patch_test.go b/ssa/abi/abi_patch_test.go index e256502d34..948e074325 100644 --- a/ssa/abi/abi_patch_test.go +++ b/ssa/abi/abi_patch_test.go @@ -46,12 +46,25 @@ func TestTypeArgs_FormatsCommonKinds(t *testing.T) { local, aliasInt, }) - const want = "[byte,*int,[]string,[2]int,map[string]int,chan int,chan<- int,<-chan int,chan (<-chan int),Local,int]" + const want = "[uint8,*int,[]string,[2]int,map[string]int,chan int,chan<- int,<-chan int,chan (<-chan int),Local,int]" if got != want { t.Fatalf("TypeArgs = %q, want %q", got, want) } } +func TestTypeArgs_CanonicalizesBasicAliases(t *testing.T) { + for _, pair := range [][2]*types.Basic{ + {types.Typ[types.Byte], types.Typ[types.Uint8]}, + {types.Typ[types.Rune], types.Typ[types.Int32]}, + } { + alias := TypeArgs([]types.Type{pair[0]}) + underlying := TypeArgs([]types.Type{pair[1]}) + if alias != underlying { + t.Fatalf("TypeArgs(%v) = %q, TypeArgs(%v) = %q", pair[0], alias, pair[1], underlying) + } + } +} + func TestNamedName_UsesTypeArgString(t *testing.T) { pkg := types.NewPackage("example.com/p", "p") generic := testGenericNamed(pkg, "Box") From c0654d7de77b751060d97af3b4fd29b86a0719c7 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 18:48:37 +0800 Subject: [PATCH 4/5] test: cover native test runner outcomes --- internal/build/run_test.go | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/build/run_test.go b/internal/build/run_test.go index 8890128176..049e02e26c 100644 --- a/internal/build/run_test.go +++ b/internal/build/run_test.go @@ -23,6 +23,8 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "strings" "sync/atomic" "testing" @@ -36,6 +38,69 @@ func testPrograms(names ...string) []testProgram { return programs } +func TestRunNativeTest(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + args := []string{"-test.run=^TestRunNativeTestHelper$", "--"} + + t.Run("success", func(t *testing.T) { + var stdout, stderr bytes.Buffer + conf := &Config{PrintCommands: true, RunArgs: append(args, "success")} + program := testProgram{app: executable, pkgDir: t.TempDir(), pkgName: "success"} + if err := runNativeTest(program, conf, &stdout, &stderr); err != nil { + t.Fatalf("runNativeTest: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "stdout") { + t.Fatalf("stdout = %q, want helper output", got) + } + if got := stderr.String(); !strings.Contains(got, executable+" ") || !strings.HasSuffix(got, "stderr") { + t.Fatalf("stderr = %q, want command followed by helper stderr", got) + } + }) + + t.Run("exit error", func(t *testing.T) { + var stderr bytes.Buffer + conf := &Config{RunArgs: append(args, "exit")} + program := testProgram{app: executable, pkgDir: t.TempDir(), pkgName: "exit"} + if err := runNativeTest(program, conf, io.Discard, &stderr); err == nil { + t.Fatal("runNativeTest unexpectedly succeeded") + } + if got := stderr.String(); !strings.Contains(got, "exit code 3") { + t.Fatalf("stderr = %q, want exit code", got) + } + }) + + t.Run("start error", func(t *testing.T) { + var stderr bytes.Buffer + program := testProgram{app: filepath.Join(t.TempDir(), "missing"), pkgName: "missing"} + if err := runNativeTest(program, &Config{}, io.Discard, &stderr); err == nil { + t.Fatal("runNativeTest unexpectedly succeeded") + } + if got := stderr.String(); !strings.Contains(got, "failed to run test") { + t.Fatalf("stderr = %q, want start error", got) + } + }) +} + +func TestRunNativeTestHelper(t *testing.T) { + args := os.Args + for i, arg := range args { + if arg != "--" || i+1 == len(args) { + continue + } + switch args[i+1] { + case "success": + fmt.Fprint(os.Stdout, "stdout") + fmt.Fprint(os.Stderr, "stderr") + case "exit": + os.Exit(3) + } + return + } +} + func TestRunTestProgramsLimitAndFailure(t *testing.T) { started := make(chan struct{}, 4) release := make(chan struct{}) From ca23d8043374fa54760f99a7a660ae1711b12469 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 19:58:59 +0800 Subject: [PATCH 5/5] test: cover test runner scheduling edges --- internal/build/run_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/internal/build/run_test.go b/internal/build/run_test.go index 049e02e26c..2150e0f68e 100644 --- a/internal/build/run_test.go +++ b/internal/build/run_test.go @@ -101,6 +101,35 @@ func TestRunNativeTestHelper(t *testing.T) { } } +func TestRunNativeTestProgramsSequential(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + programs := []testProgram{ + {app: executable, pkgDir: t.TempDir(), pkgName: "a"}, + {app: executable, pkgDir: t.TempDir(), pkgName: "b"}, + } + conf := &Config{ + BuildParallelism: 2, + TestRunSequential: true, + RunArgs: []string{"-test.run=^TestRunNativeTestHelper$", "--", "success"}, + } + var stdout, stderr bytes.Buffer + result := runNativeTestPrograms(programs, conf, &stdout, &stderr) + if result.failed || result.skipped != 0 { + t.Fatalf("runNativeTestPrograms result = %+v", result) + } + for _, name := range []string{"a", "b"} { + if !strings.Contains(stdout.String(), "ok \t"+name+"\n") { + t.Errorf("stdout does not contain result for %s: %q", name, stdout.String()) + } + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + func TestRunTestProgramsLimitAndFailure(t *testing.T) { started := make(chan struct{}, 4) release := make(chan struct{}) @@ -159,6 +188,36 @@ func TestRunTestProgramsLimitAndFailure(t *testing.T) { } } +func TestRunTestProgramsParallelismBoundsAndOutput(t *testing.T) { + if got := runTestPrograms(nil, 1, false, false, io.Discard, io.Discard, nil); got != (testRunResult{}) { + t.Fatalf("empty run result = %+v", got) + } + + for name, parallelism := range map[string]int{ + "default": 0, + "negative": -1, + "clamped": 2, + } { + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + result := runTestPrograms(testPrograms("pkg"), parallelism, false, false, &stdout, &stderr, + func(_ testProgram, output io.Writer) error { + fmt.Fprint(output, "output") + return nil + }) + if result.failed || result.skipped != 0 { + t.Fatalf("runTestPrograms result = %+v", result) + } + if got, want := stdout.String(), "output\nok \tpkg\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + }) + } +} + func TestRunTestProgramsFailFast(t *testing.T) { var runs atomic.Int32 result := runTestPrograms(testPrograms("a", "b", "c"), 1, true, false, io.Discard, io.Discard,