diff --git a/.github/workflows/_ci-gno.yml b/.github/workflows/_ci-gno.yml index 85d98ead2c5..44b631bf7af 100644 --- a/.github/workflows/_ci-gno.yml +++ b/.github/workflows/_ci-gno.yml @@ -97,6 +97,6 @@ jobs: echo "LOG_PATH_DIR=$LOG_PATH_DIR" >> "$GITHUB_ENV" - name: Run gno test - run: go run ./gnovm/cmd/gno test -C "${INPUTS_PATH}" -v -print-runtime-metrics -print-events ./... + run: go run ./gnovm/cmd/gno test -C "${INPUTS_PATH}" -v -print-runtime-metrics -print-events -jobs 4 ./... env: INPUTS_PATH: ${{ inputs.path }} diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index edccb180385..eb25d07b872 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "errors" "flag" @@ -10,7 +11,10 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" + "sync" + "sync/atomic" "time" "github.com/gnolang/gno/gnovm/pkg/gnoenv" @@ -33,6 +37,7 @@ type testCmd struct { printEvents bool debug bool debugAddr string + jobs int } func newTestCmd(io commands.IO) *commands.Command { @@ -179,6 +184,14 @@ func (c *testCmd) RegisterFlags(fs *flag.FlagSet) { "", "enable interactive debugger using tcp address in the form [host]:port", ) + + fs.IntVar( + &c.jobs, + "jobs", + 1, + "number of packages to test in parallel; 0 means GOMAXPROCS. "+ + "When above 1, the output of each package is buffered and printed once the package's tests complete.", + ) } func execTest(cmd *testCmd, args []string, io commands.IO) error { @@ -216,20 +229,22 @@ func execTest(cmd *testCmd, args []string, io commands.IO) error { }() } + if cmd.jobs != 1 && (cmd.debug || cmd.debugAddr != "") { + return errors.New("the interactive debugger can only be used with -jobs 1") + } + // Set up options to run tests. - stdout := goio.Discard - if cmd.verbose { - stdout = io.Out() + newOpts := func(stdout, stderr goio.Writer) *test.TestOptions { + opts := test.NewTestOptions(cmd.rootDir, stdout, stderr, pkgs) + opts.RunFlag = cmd.run + opts.Sync = cmd.updateGoldenTests + opts.Verbose = cmd.verbose + opts.Metrics = cmd.printRuntimeMetrics + opts.Events = cmd.printEvents + opts.Debug = cmd.debug + opts.FailfastFlag = cmd.failfast + return opts } - opts := test.NewTestOptions(cmd.rootDir, stdout, io.Err(), pkgs) - opts.RunFlag = cmd.run - opts.Sync = cmd.updateGoldenTests - opts.Verbose = cmd.verbose - opts.Metrics = cmd.printRuntimeMetrics - opts.Events = cmd.printEvents - opts.Debug = cmd.debug - opts.FailfastFlag = cmd.failfast - cache := make(gno.TypeCheckCache, 64) // test.ProdStore() is suitable for type-checking prod (non-test) files. // _, pgs := test.ProdStore(cmd.rootDir, opts.WriterForStore()) @@ -241,119 +256,218 @@ func execTest(cmd *testCmd, args []string, io commands.IO) error { return fmt.Errorf("FAIL: %d build errors, %d test errors", buildErrCount, testErrCount) } - for _, pkg := range pkgs { - // Relativize and prepend dot to pkg dir if possible - // We ignore errors since it's a cosmetic thing - // XXX: use pkg import path instead of this when printing if possible - prettyDir := pkg.Dir - if filepath.IsAbs(pkg.Dir) { - cwd, err := os.Getwd() - if err == nil { - relDir, err := filepath.Rel(cwd, pkg.Dir) - if err == nil { - prettyDir = relDir - if prettyDir != "." && !strings.HasPrefix(prettyDir, "."+string(filepath.Separator)) { - prettyDir = "." + string(filepath.Separator) + prettyDir - } - } + if cmd.jobs == 1 { + // Sequential run: all packages share a single store, and print + // their output directly as they run. + stdout := goio.Discard + if cmd.verbose { + stdout = io.Out() + } + opts := newOpts(stdout, io.Err()) + cache := make(gno.TypeCheckCache, 64) + + for _, pkg := range pkgs { + buildErrs, testErrs := cmd.testPkg(pkg, opts, cache, io) + buildErrCount += buildErrs + testErrCount += testErrs + if testErrs > 0 && cmd.failfast { + return fail() } } + } else { + // Parallel run: cmd.jobs workers, each with its own store. The + // output of each package is buffered, and printed in package order + // as results come in. + jobs := cmd.jobs + if jobs == 0 { + jobs = runtime.GOMAXPROCS(0) + } + jobs = min(jobs, len(pkgs)) - for _, err := range pkg.Errors { - io.ErrPrintfln("%s", err.Error()) - buildErrCount++ + type pkgResult struct { + out, errOut bytes.Buffer + buildErrs int + testErrs int + done chan struct{} } - // don't test packages with load errors - if len(pkg.Errors) != 0 { - io.ErrPrintfln("FAIL %s \t[setup failed]", prettyDir) - continue + results := make([]pkgResult, len(pkgs)) + for i := range results { + results[i].done = make(chan struct{}) } - // don't test packages not listed in patterns - if len(pkg.Match) == 0 { - continue + var ( + nextIdx atomic.Int64 + failed atomic.Bool + wg sync.WaitGroup + ) + for range jobs { + wg.Add(1) + go func() { + defer wg.Done() + cache := make(gno.TypeCheckCache, 64) + // One TestOptions (and store) per worker, reused across the + // packages it runs so that loaded packages are shared; only + // the writers are swapped per package. + opts := newOpts(goio.Discard, goio.Discard) + for { + i := int(nextIdx.Add(1)) - 1 + if i >= len(pkgs) { + return + } + res := &results[i] + if cmd.failfast && failed.Load() { + // don't start new tests after the first test failure + close(res.done) + continue + } + opts.Output = goio.Discard + if cmd.verbose { + opts.Output = &res.out + } + opts.Error = &res.errOut + pio := commands.NewTestIO() + pio.SetOut(commands.WriteNopCloser(&res.out)) + pio.SetErr(commands.WriteNopCloser(&res.errOut)) + res.buildErrs, res.testErrs = cmd.testPkg(pkgs[i], opts, cache, pio) + if res.testErrs > 0 { + failed.Store(true) + } + close(res.done) + } + }() } - - if len(pkg.Files[packages.FileKindTest]) == 0 && len(pkg.Files[packages.FileKindXTest]) == 0 && len(pkg.Files[packages.FileKindFiletest]) == 0 { - io.ErrPrintfln("? %s \t[no test files]", prettyDir) - continue + for i := range results { + res := &results[i] + <-res.done + if res.out.Len() > 0 { + _, _ = io.Out().Write(res.out.Bytes()) + } + if res.errOut.Len() > 0 { + _, _ = io.Err().Write(res.errOut.Bytes()) + } + buildErrCount += res.buildErrs + testErrCount += res.testErrs } + wg.Wait() + } + if testErrCount > 0 || buildErrCount > 0 { + return fail() + } - // Read and parse gnomod.toml directly. - fpath := filepath.Join(pkg.Dir, "gnomod.toml") - mod, err := gnomod.ParseFilepath(fpath) - if errors.Is(err, fs.ErrNotExist) { - if cmd.autoGnomod { - modulePath, _ := determinePkgPath(nil, pkg.Dir, cmd.rootDir) - modstr := gno.GenGnoModLatest(modulePath) - mod, err = gnomod.ParseBytes("gnomod.toml", []byte(modstr)) - if err != nil { - panic(fmt.Errorf("unexpected panic parsing default gnomod.toml bytes: %w", err)) - } - io.ErrPrintfln("auto-generated %q", fpath) - err = mod.WriteFile(fpath) - if err != nil { - panic(fmt.Errorf("unexpected panic writing to %q: %w", fpath, err)) + return nil +} + +// testPkg loads and tests pkg, printing results to io. It returns the number +// of build errors and test errors encountered. +func (c *testCmd) testPkg( + pkg *packages.Package, + opts *test.TestOptions, + cache gno.TypeCheckCache, + io commands.IO, +) (buildErrCount, testErrCount int) { + // Relativize and prepend dot to pkg dir if possible + // We ignore errors since it's a cosmetic thing + // XXX: use pkg import path instead of this when printing if possible + prettyDir := pkg.Dir + if filepath.IsAbs(pkg.Dir) { + cwd, err := os.Getwd() + if err == nil { + relDir, err := filepath.Rel(cwd, pkg.Dir) + if err == nil { + prettyDir = relDir + if prettyDir != "." && !strings.HasPrefix(prettyDir, "."+string(filepath.Separator)) { + prettyDir = "." + string(filepath.Separator) + prettyDir } - // err == nil. } } + } - // Determine pkgPath from gno.mod. - pkgPath, ok := determinePkgPath(mod, pkg.Dir, cmd.rootDir) - if !ok { - io.ErrPrintfln("WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") - } + for _, err := range pkg.Errors { + io.ErrPrintfln("%s", err.Error()) + buildErrCount++ + } + // don't test packages with load errors + if len(pkg.Errors) != 0 { + io.ErrPrintfln("FAIL %s \t[setup failed]", prettyDir) + return + } + // don't test packages not listed in patterns + if len(pkg.Match) == 0 { + return + } - // Read MemPackage with all files. - mpkg := gno.MustReadMemPackage(pkg.Dir, pkgPath, gno.MPAnyAll) - var didPanic, didError bool - startedAt := time.Now() - didPanic = catchPanic(pkg.Dir, pkgPath, io.Err(), func() { - if mod == nil || !mod.Ignore { - _, errs := lintTypeCheck(io, pkg.Dir, mpkg, gno.TypeCheckOptions{ - Getter: opts.TestStore, - TestGetter: opts.TestStore, - Mode: gno.TCLatestRelaxed, - Cache: cache, - }) - if errs != nil { - didError = true - // already printed in lintTypeCheck. - // io.ErrPrintln(errs) - return - } - } else if cmd.verbose { - io.ErrPrintfln("%s: module is ignore, skipping type check", pkgPath) + if len(pkg.Files[packages.FileKindTest]) == 0 && len(pkg.Files[packages.FileKindXTest]) == 0 && len(pkg.Files[packages.FileKindFiletest]) == 0 { + io.ErrPrintfln("? %s \t[no test files]", prettyDir) + return + } + + // Read and parse gnomod.toml directly. + fpath := filepath.Join(pkg.Dir, "gnomod.toml") + mod, err := gnomod.ParseFilepath(fpath) + if errors.Is(err, fs.ErrNotExist) { + if c.autoGnomod { + modulePath, _ := determinePkgPath(nil, pkg.Dir, c.rootDir) + modstr := gno.GenGnoModLatest(modulePath) + mod, err = gnomod.ParseBytes("gnomod.toml", []byte(modstr)) + if err != nil { + panic(fmt.Errorf("unexpected panic parsing default gnomod.toml bytes: %w", err)) + } + io.ErrPrintfln("auto-generated %q", fpath) + err = mod.WriteFile(fpath) + if err != nil { + panic(fmt.Errorf("unexpected panic writing to %q: %w", fpath, err)) } + // err == nil. + } + } + + // Determine pkgPath from gno.mod. + pkgPath, ok := determinePkgPath(mod, pkg.Dir, c.rootDir) + if !ok { + io.ErrPrintfln("WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") + } - /////////////////////////////////// - // Run the tests found in the mpkg. - errs := test.Test(mpkg, prettyDir, opts) + // Read MemPackage with all files. + mpkg := gno.MustReadMemPackage(pkg.Dir, pkgPath, gno.MPAnyAll) + var didPanic, didError bool + startedAt := time.Now() + didPanic = catchPanic(pkg.Dir, pkgPath, io.Err(), func() { + if mod == nil || !mod.Ignore { + _, errs := lintTypeCheck(io, pkg.Dir, mpkg, gno.TypeCheckOptions{ + Getter: opts.TestStore, + TestGetter: opts.TestStore, + Mode: gno.TCLatestRelaxed, + Cache: cache, + }) if errs != nil { didError = true - io.ErrPrintln(errs) + // already printed in lintTypeCheck. + // io.ErrPrintln(errs) return } - }) - - // Print status with duration. - duration := time.Since(startedAt) - dstr := fmtDuration(duration) - if didPanic || didError { - io.ErrPrintfln("FAIL %s \t%s", prettyDir, dstr) - testErrCount++ - if cmd.failfast { - return fail() - } - } else { - io.ErrPrintfln("ok %s \t%s", prettyDir, dstr) + } else if c.verbose { + io.ErrPrintfln("%s: module is ignore, skipping type check", pkgPath) } - } - if testErrCount > 0 || buildErrCount > 0 { - return fail() - } - return nil + /////////////////////////////////// + // Run the tests found in the mpkg. + errs := test.Test(mpkg, prettyDir, opts) + if errs != nil { + didError = true + io.ErrPrintln(errs) + return + } + }) + + // Print status with duration. + duration := time.Since(startedAt) + dstr := fmtDuration(duration) + if didPanic || didError { + io.ErrPrintfln("FAIL %s \t%s", prettyDir, dstr) + testErrCount++ + } else { + io.ErrPrintfln("ok %s \t%s", prettyDir, dstr) + } + return } func determinePkgPath(mod *gnomod.File, dir, rootDir string) (string, bool) { diff --git a/gnovm/pkg/gnolang/files_test.go b/gnovm/pkg/gnolang/files_test.go index a5b1d5977ef..1b56d1454fe 100644 --- a/gnovm/pkg/gnolang/files_test.go +++ b/gnovm/pkg/gnolang/files_test.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "strings" "testing" @@ -51,8 +52,19 @@ func TestFiles(t *testing.T) { ) return o } - // sharedOpts is used for all "short" tests. - sharedOpts := newOpts() + + // Tests run in parallel, drawing a TestOptions (with its store) from this + // pool. Reusing stores lets tests share previously loaded packages. + // When syncing golden files, run sequentially to keep walk-order writes + // and error propagation deterministic. + poolSize := runtime.GOMAXPROCS(0) + if *withSync { + poolSize = 1 + } + optsPool := make(chan *test.TestOptions, poolSize) + for range poolSize { + optsPool <- newOpts() + } dir := "../../tests/files" fsys := os.DirFS(dir) @@ -100,11 +112,17 @@ func TestFiles(t *testing.T) { var criticalError error t.Run(subTestName, func(t *testing.T) { - opts := sharedOpts - if isLong { - // Long tests are run in parallel, and with their own store. + if !*withSync { t.Parallel() + } + var opts *test.TestOptions + if isLong { + // Long tests get their own store, so they don't hold + // up a pool slot for their whole (long) duration. opts = newOpts() + } else { + opts = <-optsPool + defer func() { optsPool <- opts }() } changed, _, _, err := opts.RunFiletest(path, content, opts.TestStore) if err != nil { @@ -144,8 +162,6 @@ func TestStdlibs(t *testing.T) { opts.Verbose = true return } - sharedCapture, sharedOpts := newOpts() - dir := "../../stdlibs/" fsys := os.DirFS(dir) err = fs.WalkDir(fsys, ".", func(path string, de fs.DirEntry, err error) error { @@ -177,7 +193,6 @@ func TestStdlibs(t *testing.T) { // Read and run tests. mpkg := gnolang.MustReadMemPackage(fp, path, gnolang.MPStdlibAll) t.Run(strings.ReplaceAll(mpkg.Path, "/", "-"), func(t *testing.T) { - capture, opts := sharedCapture, sharedOpts switch mpkg.Path { // Excluded in short case @@ -187,20 +202,11 @@ func TestStdlibs(t *testing.T) { if testing.Short() { t.Skip("Skipped because of -short, and this stdlib is very long currently.") } - fallthrough - // Run using separate store, as it's faster - case - "math/rand", - "regexp", - "regexp/syntax", - "sort": - t.Parallel() - capture, opts = newOpts() } - if capture != nil { - capture.Reset() - } + // Run in parallel, each package using its own store. + t.Parallel() + capture, opts := newOpts() err := test.Test(mpkg, "", opts) if !testing.Verbose() { @@ -217,6 +223,9 @@ func TestStdlibs(t *testing.T) { t.Fatal(err) } + // Shared by the tests/stdlibs walk below, which runs sequentially. + sharedCapture, sharedOpts := newOpts() + testDir := "../../tests/stdlibs/" testFs := os.DirFS(testDir) err = fs.WalkDir(testFs, ".", func(path string, de fs.DirEntry, err error) error { diff --git a/gnovm/pkg/gnolang/gotypecheck.go b/gnovm/pkg/gnolang/gotypecheck.go index f46e18567d8..2d894aef437 100644 --- a/gnovm/pkg/gnolang/gotypecheck.go +++ b/gnovm/pkg/gnolang/gotypecheck.go @@ -25,20 +25,15 @@ import ( // While makeGnoBuiltins() returns a *std.MemFile to inject into each package, // they may need to import a central package if they declare any types, // otherwise each .gnobuiltins.gno would be declaring their own types. -var gnoBuiltinsCache = make(map[string]*std.MemPackage) // pkgPath -> mpkg or nil. +// The map is populated at init and must not be mutated afterwards: it is read +// concurrently by parallel type-checks. +var gnoBuiltinsCache = map[string]*std.MemPackage{ + "gnobuiltins/gno0p9": gnoBuiltinsGno0p9(), +} -func gnoBuiltinsMemPackage(pkgPath string) *std.MemPackage { - if !strings.HasPrefix(pkgPath, "gnobuiltins/") { - panic("expected pkgPath to start with gnobuiltins/") - } - mpkg, ok := gnoBuiltinsCache[pkgPath] - if ok { - return mpkg - } - switch pkgPath { - case "gnobuiltins/gno0p9": // 0.9 - mpkg = &std.MemPackage{Type: MPStdlibProd, Name: "gno0p9", Path: "gnobuiltins/gno0p9"} - mpkg.SetFile("gno0p9.gno", `package gno0p9 +func gnoBuiltinsGno0p9() *std.MemPackage { // 0.9 + mpkg := &std.MemPackage{Type: MPStdlibProd, Name: "gno0p9", Path: "gnobuiltins/gno0p9"} + mpkg.SetFile("gno0p9.gno", `package gno0p9 type realm interface { Address() address PkgPath() string @@ -58,10 +53,17 @@ func (a address) String() string { return string(a) } func (a address) IsValid() bool { return false } // shim type Address = address `) - default: + return mpkg +} + +func gnoBuiltinsMemPackage(pkgPath string) *std.MemPackage { + if !strings.HasPrefix(pkgPath, "gnobuiltins/") { + panic("expected pkgPath to start with gnobuiltins/") + } + mpkg, ok := gnoBuiltinsCache[pkgPath] + if !ok { panic("unrecognized gnobuiltins pkgpath") } - gnoBuiltinsCache[pkgPath] = mpkg return mpkg }