golang.org/x/tools/gopls@v0.15.3/internal/test/integration/bench/bench_test.go (about)

     1  // Copyright 2020 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package bench
     6  
     7  import (
     8  	"bytes"
     9  	"compress/gzip"
    10  	"context"
    11  	"flag"
    12  	"fmt"
    13  	"io"
    14  	"log"
    15  	"os"
    16  	"os/exec"
    17  	"path/filepath"
    18  	"strings"
    19  	"sync"
    20  	"testing"
    21  	"time"
    22  
    23  	"golang.org/x/tools/gopls/internal/cmd"
    24  	"golang.org/x/tools/gopls/internal/hooks"
    25  	"golang.org/x/tools/gopls/internal/protocol/command"
    26  	"golang.org/x/tools/gopls/internal/test/integration"
    27  	"golang.org/x/tools/gopls/internal/test/integration/fake"
    28  	"golang.org/x/tools/gopls/internal/util/bug"
    29  	"golang.org/x/tools/internal/event"
    30  	"golang.org/x/tools/internal/fakenet"
    31  	"golang.org/x/tools/internal/jsonrpc2"
    32  	"golang.org/x/tools/internal/jsonrpc2/servertest"
    33  	"golang.org/x/tools/internal/pprof"
    34  	"golang.org/x/tools/internal/tool"
    35  )
    36  
    37  var (
    38  	goplsPath = flag.String("gopls_path", "", "if set, use this gopls for testing; incompatible with -gopls_commit")
    39  
    40  	installGoplsOnce sync.Once // guards installing gopls at -gopls_commit
    41  	goplsCommit      = flag.String("gopls_commit", "", "if set, install and use gopls at this commit for testing; incompatible with -gopls_path")
    42  
    43  	cpuProfile   = flag.String("gopls_cpuprofile", "", "if set, the cpu profile file suffix; see \"Profiling\" in the package doc")
    44  	memProfile   = flag.String("gopls_memprofile", "", "if set, the mem profile file suffix; see \"Profiling\" in the package doc")
    45  	allocProfile = flag.String("gopls_allocprofile", "", "if set, the alloc profile file suffix; see \"Profiling\" in the package doc")
    46  	trace        = flag.String("gopls_trace", "", "if set, the trace file suffix; see \"Profiling\" in the package doc")
    47  
    48  	// If non-empty, tempDir is a temporary working dir that was created by this
    49  	// test suite.
    50  	makeTempDirOnce sync.Once // guards creation of the temp dir
    51  	tempDir         string
    52  )
    53  
    54  // if runAsGopls is "true", run the gopls command instead of the testing.M.
    55  const runAsGopls = "_GOPLS_BENCH_RUN_AS_GOPLS"
    56  
    57  func TestMain(m *testing.M) {
    58  	bug.PanicOnBugs = true
    59  	if os.Getenv(runAsGopls) == "true" {
    60  		tool.Main(context.Background(), cmd.New(hooks.Options), os.Args[1:])
    61  		os.Exit(0)
    62  	}
    63  	event.SetExporter(nil) // don't log to stderr
    64  	code := m.Run()
    65  	if err := cleanup(); err != nil {
    66  		fmt.Fprintf(os.Stderr, "cleaning up after benchmarks: %v\n", err)
    67  		if code == 0 {
    68  			code = 1
    69  		}
    70  	}
    71  	os.Exit(code)
    72  }
    73  
    74  // getTempDir returns the temporary directory to use for benchmark files,
    75  // creating it if necessary.
    76  func getTempDir() string {
    77  	makeTempDirOnce.Do(func() {
    78  		var err error
    79  		tempDir, err = os.MkdirTemp("", "gopls-bench")
    80  		if err != nil {
    81  			log.Fatal(err)
    82  		}
    83  	})
    84  	return tempDir
    85  }
    86  
    87  // shallowClone performs a shallow clone of repo into dir at the given
    88  // 'commitish' ref (any commit reference understood by git).
    89  //
    90  // The directory dir must not already exist.
    91  func shallowClone(dir, repo, commitish string) error {
    92  	if err := os.Mkdir(dir, 0750); err != nil {
    93  		return fmt.Errorf("creating dir for %s: %v", repo, err)
    94  	}
    95  
    96  	// Set a timeout for git fetch. If this proves flaky, it can be removed.
    97  	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
    98  	defer cancel()
    99  
   100  	// Use a shallow fetch to download just the relevant commit.
   101  	shInit := fmt.Sprintf("git init && git fetch --depth=1 %q %q && git checkout FETCH_HEAD", repo, commitish)
   102  	initCmd := exec.CommandContext(ctx, "/bin/sh", "-c", shInit)
   103  	initCmd.Dir = dir
   104  	if output, err := initCmd.CombinedOutput(); err != nil {
   105  		return fmt.Errorf("checking out %s: %v\n%s", repo, err, output)
   106  	}
   107  	return nil
   108  }
   109  
   110  // connectEditor connects a fake editor session in the given dir, using the
   111  // given editor config.
   112  func connectEditor(dir string, config fake.EditorConfig, ts servertest.Connector) (*fake.Sandbox, *fake.Editor, *integration.Awaiter, error) {
   113  	s, err := fake.NewSandbox(&fake.SandboxConfig{
   114  		Workdir: dir,
   115  		GOPROXY: "https://proxy.golang.org",
   116  	})
   117  	if err != nil {
   118  		return nil, nil, nil, err
   119  	}
   120  
   121  	a := integration.NewAwaiter(s.Workdir)
   122  	const skipApplyEdits = false
   123  	editor, err := fake.NewEditor(s, config).Connect(context.Background(), ts, a.Hooks(), skipApplyEdits)
   124  	if err != nil {
   125  		return nil, nil, nil, err
   126  	}
   127  
   128  	return s, editor, a, nil
   129  }
   130  
   131  // newGoplsConnector returns a connector that connects to a new gopls process,
   132  // executed with the provided arguments.
   133  func newGoplsConnector(args []string) (servertest.Connector, error) {
   134  	if *goplsPath != "" && *goplsCommit != "" {
   135  		panic("can't set both -gopls_path and -gopls_commit")
   136  	}
   137  	var (
   138  		goplsPath = *goplsPath
   139  		env       []string
   140  	)
   141  	if *goplsCommit != "" {
   142  		goplsPath = getInstalledGopls()
   143  	}
   144  	if goplsPath == "" {
   145  		var err error
   146  		goplsPath, err = os.Executable()
   147  		if err != nil {
   148  			return nil, err
   149  		}
   150  		env = []string{fmt.Sprintf("%s=true", runAsGopls)}
   151  	}
   152  	return &SidecarServer{
   153  		goplsPath: goplsPath,
   154  		env:       env,
   155  		args:      args,
   156  	}, nil
   157  }
   158  
   159  // profileArgs returns additional command-line arguments to use when invoking
   160  // gopls, to enable the user-requested profiles.
   161  //
   162  // If wantCPU is set, CPU profiling is enabled as well. Some tests may want to
   163  // instrument profiling around specific critical sections of the benchmark,
   164  // rather than the entire process.
   165  //
   166  // TODO(rfindley): like CPU, all of these would be better served by a custom
   167  // command. Very rarely do we care about memory usage as the process exits: we
   168  // care about specific points in time during the benchmark. mem and alloc
   169  // should be snapshotted, and tracing should be bracketed around critical
   170  // sections.
   171  func profileArgs(name string, wantCPU bool) []string {
   172  	var args []string
   173  	if wantCPU && *cpuProfile != "" {
   174  		args = append(args, fmt.Sprintf("-profile.cpu=%s", qualifiedName(name, *cpuProfile)))
   175  	}
   176  	if *memProfile != "" {
   177  		args = append(args, fmt.Sprintf("-profile.mem=%s", qualifiedName(name, *memProfile)))
   178  	}
   179  	if *allocProfile != "" {
   180  		args = append(args, fmt.Sprintf("-profile.alloc=%s", qualifiedName(name, *allocProfile)))
   181  	}
   182  	if *trace != "" {
   183  		args = append(args, fmt.Sprintf("-profile.trace=%s", qualifiedName(name, *trace)))
   184  	}
   185  	return args
   186  }
   187  
   188  func qualifiedName(args ...string) string {
   189  	return strings.Join(args, ".")
   190  }
   191  
   192  // getInstalledGopls builds gopls at the given -gopls_commit, returning the
   193  // path to the gopls binary.
   194  func getInstalledGopls() string {
   195  	if *goplsCommit == "" {
   196  		panic("must provide -gopls_commit")
   197  	}
   198  	toolsDir := filepath.Join(getTempDir(), "gopls_build")
   199  	goplsPath := filepath.Join(toolsDir, "gopls", "gopls")
   200  
   201  	installGoplsOnce.Do(func() {
   202  		log.Printf("installing gopls: checking out x/tools@%s into %s\n", *goplsCommit, toolsDir)
   203  		if err := shallowClone(toolsDir, "https://go.googlesource.com/tools", *goplsCommit); err != nil {
   204  			log.Fatal(err)
   205  		}
   206  
   207  		log.Println("installing gopls: building...")
   208  		bld := exec.Command("go", "build", ".")
   209  		bld.Dir = filepath.Join(toolsDir, "gopls")
   210  		if output, err := bld.CombinedOutput(); err != nil {
   211  			log.Fatalf("building gopls: %v\n%s", err, output)
   212  		}
   213  
   214  		// Confirm that the resulting path now exists.
   215  		if _, err := os.Stat(goplsPath); err != nil {
   216  			log.Fatalf("os.Stat(%s): %v", goplsPath, err)
   217  		}
   218  	})
   219  	return goplsPath
   220  }
   221  
   222  // A SidecarServer starts (and connects to) a separate gopls process at the
   223  // given path.
   224  type SidecarServer struct {
   225  	goplsPath string
   226  	env       []string // additional environment bindings
   227  	args      []string // command-line arguments
   228  }
   229  
   230  // Connect creates new io.Pipes and binds them to the underlying StreamServer.
   231  //
   232  // It implements the servertest.Connector interface.
   233  func (s *SidecarServer) Connect(ctx context.Context) jsonrpc2.Conn {
   234  	// Note: don't use CommandContext here, as we want gopls to exit gracefully
   235  	// in order to write out profile data.
   236  	//
   237  	// We close the connection on context cancelation below.
   238  	cmd := exec.Command(s.goplsPath, s.args...)
   239  
   240  	stdin, err := cmd.StdinPipe()
   241  	if err != nil {
   242  		log.Fatal(err)
   243  	}
   244  	stdout, err := cmd.StdoutPipe()
   245  	if err != nil {
   246  		log.Fatal(err)
   247  	}
   248  	cmd.Stderr = os.Stderr
   249  	cmd.Env = append(os.Environ(), s.env...)
   250  	if err := cmd.Start(); err != nil {
   251  		log.Fatalf("starting gopls: %v", err)
   252  	}
   253  
   254  	go func() {
   255  		// If we don't log.Fatal here, benchmarks may hang indefinitely if gopls
   256  		// exits abnormally.
   257  		//
   258  		// TODO(rfindley): ideally we would shut down the connection gracefully,
   259  		// but that doesn't currently work.
   260  		if err := cmd.Wait(); err != nil {
   261  			log.Fatalf("gopls invocation failed with error: %v", err)
   262  		}
   263  	}()
   264  
   265  	clientStream := jsonrpc2.NewHeaderStream(fakenet.NewConn("stdio", stdout, stdin))
   266  	clientConn := jsonrpc2.NewConn(clientStream)
   267  
   268  	go func() {
   269  		select {
   270  		case <-ctx.Done():
   271  			clientConn.Close()
   272  			clientStream.Close()
   273  		case <-clientConn.Done():
   274  		}
   275  	}()
   276  
   277  	return clientConn
   278  }
   279  
   280  // startProfileIfSupported checks to see if the remote gopls instance supports
   281  // the start/stop profiling commands. If so, it starts profiling and returns a
   282  // function that stops profiling and records the total CPU seconds sampled in the
   283  // cpu_seconds benchmark metric.
   284  //
   285  // If the remote gopls instance does not support profiling commands, this
   286  // function returns nil.
   287  //
   288  // If the supplied userSuffix is non-empty, the profile is written to
   289  // <repo>.<userSuffix>, and not deleted when the benchmark exits. Otherwise,
   290  // the profile is written to a temp file that is deleted after the cpu_seconds
   291  // metric has been computed.
   292  func startProfileIfSupported(b *testing.B, env *integration.Env, name string) func() {
   293  	if !env.Editor.HasCommand(command.StartProfile.ID()) {
   294  		return nil
   295  	}
   296  	b.StopTimer()
   297  	stopProfile := env.StartProfile()
   298  	b.StartTimer()
   299  	return func() {
   300  		b.StopTimer()
   301  		profFile := stopProfile()
   302  		totalCPU, err := totalCPUForProfile(profFile)
   303  		if err != nil {
   304  			b.Fatalf("reading profile: %v", err)
   305  		}
   306  		b.ReportMetric(totalCPU.Seconds()/float64(b.N), "cpu_seconds/op")
   307  		if *cpuProfile == "" {
   308  			// The user didn't request profiles, so delete it to clean up.
   309  			if err := os.Remove(profFile); err != nil {
   310  				b.Errorf("removing profile file: %v", err)
   311  			}
   312  		} else {
   313  			// NOTE: if this proves unreliable (due to e.g. EXDEV), we can fall back
   314  			// on Read+Write+Remove.
   315  			name := qualifiedName(name, *cpuProfile)
   316  			if err := os.Rename(profFile, name); err != nil {
   317  				b.Fatalf("renaming profile file: %v", err)
   318  			}
   319  		}
   320  	}
   321  }
   322  
   323  // totalCPUForProfile reads the pprof profile with the given file name, parses,
   324  // and aggregates the total CPU sampled during the profile.
   325  func totalCPUForProfile(filename string) (time.Duration, error) {
   326  	protoGz, err := os.ReadFile(filename)
   327  	if err != nil {
   328  		return 0, err
   329  	}
   330  	rd, err := gzip.NewReader(bytes.NewReader(protoGz))
   331  	if err != nil {
   332  		return 0, fmt.Errorf("creating gzip reader for %s: %v", filename, err)
   333  	}
   334  	data, err := io.ReadAll(rd)
   335  	if err != nil {
   336  		return 0, fmt.Errorf("reading %s: %v", filename, err)
   337  	}
   338  	return pprof.TotalTime(data)
   339  }
   340  
   341  // closeBuffer stops the benchmark timer and closes the buffer with the given
   342  // name.
   343  //
   344  // It may be used to clean up files opened in the shared environment during
   345  // benchmarking.
   346  func closeBuffer(b *testing.B, env *integration.Env, name string) {
   347  	b.StopTimer()
   348  	env.CloseBuffer(name)
   349  	env.AfterChange()
   350  	b.StartTimer()
   351  }