blob: c4ebf8cb73dd1390eb6445b5956b125b5300b11b [file]
// Copyright 2026 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cmd_test
// This file tests the automatic daemon management of the -remote flag;
// see gopls/doc/daemon.md.
//
// The daemon these tests start is the test executable itself, re-executed
// as gopls: the gopls subprocess started by [gopls] passes its environment
// (including ENTRYPOINT) to the daemon that it starts.
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"text/template"
)
const remoteFiles = `
-- go.mod --
module example.com
go 1.18
-- a.go --
package a
const Hello = "hello"
var _ = Hello
-- a_test.go --
package a
import "testing"
func TestFoo(t *testing.T) {}
`
// TestRemoteAuto tests that -remote=auto starts a daemon if none is running,
// that subsequent commands reuse it, and that subcommands run against the
// daemon exchange data with the client in both directions.
func TestRemoteAuto(t *testing.T) {
needsAutoRemote(t)
tree := writeTree(t, remoteFiles)
xdg := runtimeDir(t)
env := []string{"XDG_RUNTIME_DIR=" + xdg}
// Use a dedicated daemon id so that this test shares neither with
// other tests nor with the daemon of the user running the test.
// (Keep the id short: it is a component of a unix socket name.)
const remote = "-remote=auto;t1"
// 'gopls remote' must not start a daemon.
{
res := goplsWithEnv(t, tree, env, remote, "remote", "sessions")
res.checkExit(false)
res.checkStderr("no gopls daemon is listening")
checkEmptyDir(t, xdg) // no socket was created
}
// The result of a command must not depend on where it is computed.
want := gopls(t, tree, "definition", "a.go:5:9")
want.checkExit(true)
want.checkStdout(`a.go:3:7-12: defined here as`)
// The first command starts the daemon...
log1 := filepath.Join(xdg, "daemon1.log")
first := goplsWithEnv(t, tree, env, remote,
"-remote.logfile="+log1,
"-remote.listen.timeout="+listenTimeout,
"definition", "a.go:5:9",
)
first.checkExit(true)
if first.stdout != want.stdout {
t.Errorf("%s: stdout = <<%s>>, want <<%s>> (the result of the same command without -remote)",
first.command, first.stdout, want.stdout)
}
// ...and the second reuses it. The -remote.* flags of the second command
// are ignored, since they only configure a daemon that is being started,
// so its log file must not exist.
log2 := filepath.Join(xdg, "daemon2.log")
second := goplsWithEnv(t, tree, env, remote,
"-remote.logfile="+log2,
"-remote.listen.timeout="+listenTimeout,
"definition", "a.go:5:9",
)
second.checkExit(true)
if second.stdout != want.stdout {
t.Errorf("%s: stdout = <<%s>>, want <<%s>> (the result of the same command without -remote)",
second.command, second.stdout, want.stdout)
}
if _, err := os.Stat(log2); !os.IsNotExist(err) {
t.Errorf("%s started a second daemon: %s exists (err=%v)", second.command, log2, err)
}
// The daemon log confirms that one daemon served both commands.
daemonLog := mustReadFile(t, log1)
if got, want := strings.Count(daemonLog, "daemon: listening on"), 1; got != want {
t.Errorf("daemon log has %d start messages, want %d; log:\n%s", got, want, daemonLog)
}
if got, want := strings.Count(daemonLog, ": connected"), 2; got != want {
t.Errorf("daemon log has %d session connections, want %d; log:\n%s", got, want, daemonLog)
}
// The daemon outlives the commands that used it.
sessions := goplsWithEnv(t, tree, env, remote,
"remote", "sessions",
)
sessions.checkExit(true)
sessions.checkStdout(`"goplsPath"`)
// Subcommands run against the daemon still exchange data with the
// client in both directions.
tree = writeTree(t, `
-- go.mod --
module example.com
go 1.18
-- a.go --
package a
import "fmt"
var _ = fmt.Sprintf("%d","123")
`)
remoteArgs := func(args ...string) []string {
return append([]string{remote, "-remote.listen.timeout=" + listenTimeout}, args...)
}
// check: a diagnostic computed by the daemon reaches the client.
t.Run("check", func(t *testing.T) {
res := goplsWithEnv(t, tree, env, remoteArgs("check", "./a.go")...)
res.checkExit(true)
res.checkStdout(`a.go:.* fmt.Sprintf format %d has arg "123" of wrong type string`)
})
// execute: the daemon sends the client a workspace/applyEdit request
// while the client is waiting for the response to its own
// workspace/executeCommand request.
t.Run("execute", func(t *testing.T) {
uri := "file://" + filepath.ToSlash(tree) + "/a.go"
res := goplsWithEnv(t, tree, env, remoteArgs("execute", "-d", "gopls.add_import",
`{"ImportPath": "os", "URI": "`+uri+`"}`)...)
res.checkExit(true)
res.checkStdout(`[+].*"os"`)
})
// format -w: edits computed by the daemon are written to the client's
// files. (This case comes last because it rewrites a.go, which the cases
// above expect to be unformatted.)
t.Run("format", func(t *testing.T) {
res := goplsWithEnv(t, tree, env, remoteArgs("format", "-w", "./a.go")...)
res.checkExit(true)
checkContent(t, filepath.Join(tree, "a.go"), `package a
import "fmt"
var _ = fmt.Sprintf("%d", "123")
`)
})
}
// TestRemoteExplicitAddress tests that only an automatic address starts a
// daemon: an explicit one must connect to a daemon that already exists.
func TestRemoteExplicitAddress(t *testing.T) {
t.Parallel()
needsAutoRemote(t) // (the test uses a unix domain socket)
tree := writeTree(t, remoteFiles)
dir := runtimeDir(t)
socket := filepath.Join(dir, "sock")
res := gopls(t, tree, "-remote=unix;"+socket, "definition", "a.go:5:9")
res.checkExit(false)
res.checkStderr("failed to dial remote")
checkEmptyDir(t, dir) // no daemon bound the socket
}
// TestRemoteAutoEnvParity tests that environment variables set on the client
// (such as GOARCH) are honored by a daemon started by a client with a
// different environment.
func TestRemoteAutoEnvParity(t *testing.T) {
t.Parallel()
needsAutoRemote(t)
altArch := "arm64"
if runtime.GOARCH == "arm64" {
altArch = "amd64"
}
const treeTmpl = `
-- go.mod --
module example.com
go 1.18
-- arch_{{.HostArch}}.go --
//go:build {{.HostArch}}
package a
const Arch = "{{.HostArch}}"
-- arch_{{.AltArch}}.go --
//go:build {{.AltArch}}
package a
const Arch = "{{.AltArch}}"
-- a.go --
package a
var _ = Arch
`
tmpl := template.Must(template.New("tree").Parse(treeTmpl))
var buf strings.Builder
if err := tmpl.Execute(&buf, map[string]string{
"HostArch": runtime.GOARCH,
"AltArch": altArch,
}); err != nil {
t.Fatal(err)
}
tree := writeTree(t, buf.String())
xdg := runtimeDir(t)
baseEnv := []string{"XDG_RUNTIME_DIR=" + xdg}
remote := []string{"-remote=auto;envparity", "-remote.listen.timeout=" + listenTimeout}
// First command starts the daemon using the host's default GOARCH.
{
res := goplsWithEnv(t, tree, baseEnv, append(remote, "definition", "a.go:3:9")...)
res.checkExit(true)
res.checkStdout(fmt.Sprintf(`arch_%s\.go:5:7-11: defined here as const Arch`, runtime.GOARCH))
}
// Second command reuses the running daemon, but sets GOARCH to altArch in
// its environment. The daemon must evaluate the query using the client's
// GOARCH, not the daemon's host GOARCH.
//
// GOOS is also set so that the GOOS/GOARCH pair is valid regardless of
// the host OS (e.g. solaris/arm64 is unsupported; see golang/go#82199).
{
clientEnv := append([]string{"GOOS=linux", "GOARCH=" + altArch}, baseEnv...)
res := goplsWithEnv(t, tree, clientEnv, append(remote, "definition", "a.go:3:9")...)
res.checkExit(true)
res.checkStdout(fmt.Sprintf(`arch_%s\.go:5:7-11: defined here as const Arch`, altArch))
}
}
// TestRemoteAutoOptionsParity tests that client configuration options
// (such as symbol matcher and codelenses["test"]) are propagated to the
// daemon and honored across requests.
func TestRemoteAutoOptionsParity(t *testing.T) {
t.Parallel()
needsAutoRemote(t)
tree := writeTree(t, remoteFiles)
xdg := runtimeDir(t)
baseEnv := []string{"XDG_RUNTIME_DIR=" + xdg}
remote := []string{"-remote=auto;optparity", "-remote.listen.timeout=" + listenTimeout}
// 1. workspace_symbol matcher parity:
// A CaseSensitive query for lowercase "hello" must not match "Hello",
// while a query for "Hello" must match.
{
res := goplsWithEnv(t, tree, baseEnv, append(remote, "workspace_symbol", "-matcher=CaseSensitive", "hello")...)
res.checkExit(true)
if strings.Contains(res.stdout, "Hello") {
t.Errorf("workspace_symbol -matcher=CaseSensitive found Hello for 'hello': %s", res.stdout)
}
}
{
res := goplsWithEnv(t, tree, baseEnv, append(remote, "workspace_symbol", "-matcher=CaseSensitive", "Hello")...)
res.checkExit(true)
res.checkStdout(`a\.go:3:7-12 Hello Constant`)
}
// 2. codelens parity:
// 'codelens' overrides the default for codelenses["test"] (off by default)
// to true. The daemon must receive this setting and return the test lens.
{
want := gopls(t, tree, "codelens", "a_test.go")
want.checkExit(true)
want.checkStdout("run test")
res := goplsWithEnv(t, tree, baseEnv, append(remote, "codelens", "a_test.go")...)
res.checkExit(true)
if res.stdout != want.stdout {
t.Errorf("codelens -remote stdout = <<%s>>, want <<%s>>", res.stdout, want.stdout)
}
}
}
// listenTimeout is the -remote.listen.timeout of the daemons started by these
// tests. It must exceed the interval between two successive commands, or a
// test will start a second daemon and fail; and the daemon lingers for this
// long after the test that started it. (The integration test runner makes the
// same trade-off; see ../test/integration/runner.go.)
const listenTimeout = "1m"
// needsAutoRemote skips the test if it cannot start a daemon with
// -remote=auto.
func needsAutoRemote(t *testing.T) {
switch runtime.GOOS {
case "windows", "plan9":
// On these systems, "auto" resolves to a fixed TCP port shared by the
// whole machine, and "auto;<id>" panics; see
// lsprpc.autoNetworkAddressDefault.
t.Skipf("-remote=auto is not isolated on %s", runtime.GOOS)
}
}
// runtimeDir returns a new temporary directory to use as XDG_RUNTIME_DIR, so
// that a test neither uses nor disturbs the daemon of the user running it.
//
// Unlike t.TempDir, the name is short, because it must accommodate the name of
// a unix socket, whose length is limited to about 100 bytes.
func runtimeDir(t *testing.T) string {
dir, err := os.MkdirTemp("", "g")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
// The daemon may still be listening on a socket in this directory;
// removing it merely makes the daemon unreachable.
os.RemoveAll(dir) // ignore error
})
return dir
}
// checkEmptyDir asserts that the directory has no entries.
func checkEmptyDir(t *testing.T, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) > 0 {
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
t.Errorf("directory %s is not empty: %s", dir, strings.Join(names, " "))
}
}
func mustReadFile(t *testing.T, filename string) string {
t.Helper()
data, err := os.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
return string(data)
}