blob: 8434fa46577b1aa9275b1f472a0e2d49d0af96fb [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 (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
const remoteFiles = `
-- go.mod --
module example.com
go 1.18
-- a.go --
package a
const Hello = "hello"
var _ = Hello
`
// TestRemoteAuto tests that -remote=auto starts a daemon if none is running,
// and that a subsequent command reuses it.
func TestRemoteAuto(t *testing.T) {
t.Parallel()
needsAutoRemote(t)
tree := writeTree(t, remoteFiles)
xdg := runtimeDir(t)
env := []string{"XDG_RUNTIME_DIR=" + xdg}
// Each test uses its own daemon, so that it shares neither with
// concurrent tests nor with the daemon of the user running the test.
// (Keep the id short: it is a component of a unix socket name.)
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"`)
}
// 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
}
// TestRemoteAutoFeatures tests that a subcommand run against a daemon still
// exchanges data with the client in both directions.
func TestRemoteAutoFeatures(t *testing.T) {
t.Parallel()
needsAutoRemote(t)
tree := writeTree(t, `
-- go.mod --
module example.com
go 1.18
-- a.go --
package a
import "fmt"
var _ = fmt.Sprintf("%d","123")
`)
xdg := runtimeDir(t)
env := []string{"XDG_RUNTIME_DIR=" + xdg}
remote := func(args ...string) []string {
return append([]string{"-remote=auto;t2", "-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, remote("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, remote("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, remote("format", "-w", "./a.go")...)
res.checkExit(true)
checkContent(t, filepath.Join(tree, "a.go"), `package a
import "fmt"
var _ = fmt.Sprintf("%d", "123")
`)
})
}
// 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) {
if runtime.GOOS == "windows" {
// On Windows, "auto" resolves to a fixed TCP port shared by the whole
// machine, and "auto;<id>" panics; see lsprpc.autoNetworkAddressDefault.
t.Skip("-remote=auto is not isolated on windows")
}
}
// 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)
}