Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

712 lines
22 KiB
Go

package lsp
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/spf13/viper"
"github.com/zzet/gortex/internal/config"
)
// fakeLSPSocketServer accepts a single client connection on a TCP or unix
// socket and replies to LSP requests via a caller-supplied handler.
// It records every received method name so the test can assert what
// the Provider sent (or, critically for shutdown semantics, what it
// did NOT send).
type fakeLSPSocketServer struct {
t *testing.T
listener net.Listener
mu sync.Mutex
conn net.Conn
methods []string
// pushed is fired once the server has accepted its first conn.
// Tests can wait on it before tearing the server down.
pushed chan struct{}
// handler answers a request method. Return (result, ok) — ok=false
// makes the server send a method-not-found error.
handler func(method string, params json.RawMessage) (any, bool)
// stopped guards the accept loop's lifecycle.
stopped chan struct{}
}
// newFakeLSPSocketServer starts a server on the given network. For "tcp" the
// address is "127.0.0.1:0" (an OS-assigned port); for "unix" the
// address is a fresh socket path under t.TempDir().
//
// The returned addr is what the test passes to DialTransport.Address.
// Cleanup is registered via t.Cleanup.
func newFakeLSPSocketServer(t *testing.T, network string) (server *fakeLSPSocketServer, addr string) {
t.Helper()
var (
ln net.Listener
err error
)
switch network {
case "tcp":
ln, err = net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr = ln.Addr().String()
case "unix":
// Keep the path short — macOS sun_path is capped at 104B
// (Linux: 108B). t.TempDir() under /var/folders/... can
// exceed that, so we use os.MkdirTemp under /tmp with a
// short suffix instead.
dir, err := os.MkdirTemp("", "lsp")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
addr = filepath.Join(dir, "s.sock")
ln, err = net.Listen("unix", addr)
require.NoError(t, err)
default:
t.Fatalf("unknown fake-LSP network %q", network)
}
server = &fakeLSPSocketServer{
t: t,
listener: ln,
pushed: make(chan struct{}, 1),
stopped: make(chan struct{}),
}
server.handler = server.defaultHandler
t.Cleanup(func() { _ = server.Close() })
go server.acceptLoop()
return server, addr
}
// Close shuts the listener (and any active conn) down. Safe to call
// twice — the cleanup hook and an explicit test teardown can race.
func (s *fakeLSPSocketServer) Close() error {
s.mu.Lock()
// Closing the listener races with acceptLoop's blocking Accept;
// the loop's select on stopped unblocks the conn-handler exit.
select {
case <-s.stopped:
s.mu.Unlock()
return nil
default:
close(s.stopped)
}
conn := s.conn
s.conn = nil
s.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
return s.listener.Close()
}
// Methods returns a copy of the received method names — convenient for
// assertions like "did the server receive shutdown/exit?".
func (s *fakeLSPSocketServer) Methods() []string {
s.mu.Lock()
defer s.mu.Unlock()
cp := make([]string, len(s.methods))
copy(cp, s.methods)
return cp
}
// CloseConn drops the active client connection without stopping the
// listener — simulates "the IDE quit but the listening socket is back".
// Tests use this to exercise reconnect.
func (s *fakeLSPSocketServer) CloseConn() {
s.mu.Lock()
conn := s.conn
s.conn = nil
s.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
}
// PushRequest sends a server-initiated request to the connected client.
// Used by the capability-reset test to drive a registerCapability over
// the dialed connection.
func (s *fakeLSPSocketServer) PushRequest(id any, method string, params any) error {
s.mu.Lock()
conn := s.conn
s.mu.Unlock()
if conn == nil {
return errors.New("fake server: no connected client")
}
body, err := json.Marshal(struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id"`
Method string `json:"method"`
Params any `json:"params"`
}{"2.0", id, method, params})
if err != nil {
return err
}
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
if _, err := conn.Write([]byte(header)); err != nil {
return err
}
_, err = conn.Write(body)
return err
}
// SetHandler installs a custom request handler. The default handler
// answers initialize with an empty ServerCapabilities and any other
// method with an empty result object.
func (s *fakeLSPSocketServer) SetHandler(h func(method string, params json.RawMessage) (any, bool)) {
s.mu.Lock()
defer s.mu.Unlock()
s.handler = h
}
func (s *fakeLSPSocketServer) defaultHandler(method string, _ json.RawMessage) (any, bool) {
switch method {
case "initialize":
return InitializeResult{Capabilities: ServerCapabilities{}}, true
case "shutdown":
return nil, true
default:
return struct{}{}, true
}
}
func (s *fakeLSPSocketServer) acceptLoop() {
conn, err := s.listener.Accept()
if err != nil {
// Listener closed before any client connected. Normal at
// teardown; nothing to do.
return
}
s.mu.Lock()
s.conn = conn
s.mu.Unlock()
select {
case s.pushed <- struct{}{}:
default:
}
s.serve(conn)
}
func (s *fakeLSPSocketServer) serve(conn net.Conn) {
rd := bufio.NewReader(conn)
for {
contentLength := -1
for {
line, err := rd.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
break
}
if strings.HasPrefix(line, "Content-Length:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "Content-Length:"))
contentLength, _ = strconv.Atoi(val)
}
}
if contentLength < 0 {
continue
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(rd, body); err != nil {
return
}
var msg struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(body, &msg); err != nil {
continue
}
if msg.Method != "" {
s.mu.Lock()
s.methods = append(s.methods, msg.Method)
h := s.handler
s.mu.Unlock()
// Notifications carry no id; nothing to reply with.
if len(msg.ID) == 0 {
continue
}
if h == nil {
h = s.defaultHandler
}
result, ok := h(msg.Method, msg.Params)
if !ok {
resp, _ := json.Marshal(struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Error any `json:"error"`
}{"2.0", msg.ID, map[string]any{
"code": -32601,
"message": "method not found: " + msg.Method,
}})
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(resp))
_, _ = conn.Write([]byte(header))
_, _ = conn.Write(resp)
continue
}
resp, _ := json.Marshal(struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result"`
}{"2.0", msg.ID, result})
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(resp))
_, _ = conn.Write([]byte(header))
_, _ = conn.Write(resp)
continue
}
// Otherwise it's a response to a server-initiated request
// (the client acking a reverse RPC). We don't track them
// in defaultHandler; tests that care can install a custom
// handler.
}
}
// passiveProvider builds a Provider that dials the given network/addr.
// FallbackSpawn is opt-in; callers that test the no-fallback path pass
// fallback=false; callers that test the spawn-fallback path pass true
// plus a spec-style command.
func passiveProvider(t *testing.T, network, addr string, fallback bool) *Provider {
t.Helper()
spec := &ServerSpec{
Name: "fake-passive",
Command: "/bin/echo", // present on every POSIX runner; never invoked unless fallback triggers.
Languages: []string{"go"},
Connect: &ConnectSpec{
Network: network,
Address: addr,
FallbackSpawn: fallback,
},
}
return NewProviderFromSpec(spec, zap.NewNop())
}
// callMethod issues a synthetic JSON-RPC request on the provider's
// active client and returns true on a successful round trip.
func callMethod(t *testing.T, p *Provider, method string) error {
t.Helper()
require.NotNil(t, p.client, "provider has no client")
return p.client.Call(method, struct{}{}, nil)
}
// TestPassiveProvider_TCPDialHappyPath establishes a fake LSP server
// on 127.0.0.1:0 and verifies that EnsureClient completes the
// initialize handshake against the dialed connection. A follow-up
// synthetic request confirms a round-trip works end-to-end.
func TestPassiveProvider_TCPDialHappyPath(t *testing.T) {
server, addr := newFakeLSPSocketServer(t, "tcp")
defer server.Close()
p := passiveProvider(t, "tcp", addr, false)
defer p.Close()
require.NoError(t, p.EnsureClient(t.TempDir()))
// Wait briefly for the fake server's accept loop to record
// the initialize call — Accept happens on a separate goroutine.
require.Eventually(t, func() bool {
for _, m := range server.Methods() {
if m == "initialize" {
return true
}
}
return false
}, time.Second, 5*time.Millisecond, "fake server did not receive initialize")
require.NoError(t, callMethod(t, p, "textDocument/hover"))
gotHover := false
for _, m := range server.Methods() {
if m == "textDocument/hover" {
gotHover = true
}
}
require.True(t, gotHover, "fake server did not receive textDocument/hover: got %v", server.Methods())
}
// TestPassiveProvider_UnixDialHappyPath mirrors the TCP test using a
// Unix-domain socket. Skipped on Windows where AF_UNIX support is
// limited and varies by runner image.
func TestPassiveProvider_UnixDialHappyPath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix-domain sockets are not exercised in the Windows CI image")
}
server, addr := newFakeLSPSocketServer(t, "unix")
defer server.Close()
p := passiveProvider(t, "unix", addr, false)
defer p.Close()
require.NoError(t, p.EnsureClient(t.TempDir()))
require.Eventually(t, func() bool {
for _, m := range server.Methods() {
if m == "initialize" {
return true
}
}
return false
}, time.Second, 5*time.Millisecond, "fake server did not receive initialize over unix socket")
}
// TestPassiveProvider_DialFailsNoFallback verifies that a closed
// endpoint with fallback_spawn=false produces a clear dial error and
// does NOT spawn a subprocess. We assert the spawn path was avoided by
// checking that the error message describes the dial failure (the
// subprocess path would surface a different "start <cmd>" error).
func TestPassiveProvider_DialFailsNoFallback(t *testing.T) {
// Allocate a TCP listener just to grab a free port, then close
// it so the dial fails with "connection refused".
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
deadAddr := ln.Addr().String()
require.NoError(t, ln.Close())
p := passiveProvider(t, "tcp", deadAddr, false)
defer p.Close()
err = p.EnsureClient(t.TempDir())
require.Error(t, err, "EnsureClient should fail when dial fails with no fallback")
require.Contains(t, err.Error(), "passive attach",
"error should identify passive-attach failure mode, not a spawn-path message: %v", err)
require.Nil(t, p.client, "no client should be cached on dial-only failure")
}
// TestPassiveProvider_DialFailsSpawnFallback verifies that a closed
// endpoint with fallback_spawn=true falls back to spawning the spec's
// command. The fixture's "command" is set to a no-op binary that
// blocks on stdin, so the spawn succeeds and the initialize handshake
// then fails (because /bin/cat is not a real LSP server). We just need
// to confirm the spawn was attempted, which we detect via the spec's
// SpawnTransport — the Provider's client.transport is reachable
// internally because the test is in the same package.
func TestPassiveProvider_DialFailsSpawnFallback(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
deadAddr := ln.Addr().String()
require.NoError(t, ln.Close())
// /bin/cat with no args blocks on stdin without writing anything,
// so the spawn succeeds but the (immediate) initialize Call we
// don't make would have blocked indefinitely. We don't invoke
// initialize here — dialOrSpawn just constructs the Client.
// /bin/cat exits cleanly when we close stdin during Shutdown,
// so the deferred Close is fast.
spec := &ServerSpec{
Name: "fake-passive-spawn-fallback",
Command: "/bin/cat",
Args: nil,
Languages: []string{"go"},
Connect: &ConnectSpec{
Network: "tcp",
Address: deadAddr,
FallbackSpawn: true,
},
}
p := NewProviderFromSpec(spec, zap.NewNop())
defer p.Close()
// We don't await initialize success — /bin/sleep is not a real
// LSP server, so the handshake will eventually time out. The
// dialOrSpawn helper is what we're verifying: it must succeed
// on the spawn fallback. Run it directly to keep the test fast.
client, err := p.dialOrSpawn(t.TempDir())
require.NoError(t, err, "dialOrSpawn should fall back to spawn")
defer func() { _ = client.Shutdown() }()
// The transport on the freshly built client must be a spawn
// transport — that's the proof the dial was abandoned and the
// fallback engaged.
_, isSpawn := client.transport.(*SpawnTransport)
require.True(t, isSpawn, "client transport should be a SpawnTransport after fallback, got %T", client.transport)
}
// TestPassiveProvider_ShutdownDoesNotSendExit confirms that closing a
// dialed Provider does NOT send `shutdown`/`exit` to the IDE-managed
// LSP server. Only the socket is closed; the IDE keeps its server up.
func TestPassiveProvider_ShutdownDoesNotSendExit(t *testing.T) {
server, addr := newFakeLSPSocketServer(t, "tcp")
defer server.Close()
p := passiveProvider(t, "tcp", addr, false)
require.NoError(t, p.EnsureClient(t.TempDir()))
// Wait for initialize so we know the connection is live.
require.Eventually(t, func() bool {
for _, m := range server.Methods() {
if m == "initialize" {
return true
}
}
return false
}, time.Second, 5*time.Millisecond)
require.NoError(t, p.Close(), "Provider.Close")
// Give the server's read loop a moment to drain anything in
// flight — shutdown/exit would arrive shortly after Close().
time.Sleep(50 * time.Millisecond)
for _, m := range server.Methods() {
require.NotEqual(t, "shutdown", m, "passive attach must not send shutdown: full transcript %v", server.Methods())
require.NotEqual(t, "exit", m, "passive attach must not send exit: full transcript %v", server.Methods())
}
}
// TestPassiveProvider_ServerEOFReconnect drops the connection mid-
// session and verifies the next EnsureClient re-dials and re-completes
// initialize. The Provider's reconnect path resets per-connection
// state (open docs, dynamic caps); the server records two
// initialize calls — one before, one after the reconnect.
func TestPassiveProvider_ServerEOFReconnect(t *testing.T) {
server, addr := newFakeLSPSocketServer(t, "tcp")
defer server.Close()
p := passiveProvider(t, "tcp", addr, false)
defer p.Close()
require.NoError(t, p.EnsureClient(t.TempDir()))
require.Eventually(t, func() bool {
for _, m := range server.Methods() {
if m == "initialize" {
return true
}
}
return false
}, time.Second, 5*time.Millisecond, "first initialize")
// Drop the connection on the server side; relaunch acceptLoop
// so the next dial gets a fresh socket.
server.CloseConn()
// Start a new accept loop on the existing listener so the
// re-dial finds a partner.
go server.acceptLoop()
// The Provider's read loop notices EOF and closes Done(). Wait
// for that so the next EnsureClient sees the dead-client path.
require.Eventually(t, func() bool {
select {
case <-p.client.Done():
return true
default:
return false
}
}, time.Second, 5*time.Millisecond, "client.Done did not fire after server EOF")
require.NoError(t, p.EnsureClient(t.TempDir()), "reconnect should succeed")
// Two initialize calls in the transcript prove the reconnect
// performed a fresh handshake.
require.Eventually(t, func() bool {
count := 0
for _, m := range server.Methods() {
if m == "initialize" {
count++
}
}
return count >= 2
}, time.Second, 5*time.Millisecond, "second initialize after reconnect: transcript %v", server.Methods())
}
// TestPassiveProvider_CapabilityResetOnReconnect drives a dynamic
// capability registration on the first connection, then forces a
// reconnect. After reconnect, the dynamicCaps map must be empty until
// the new connection's registerCapability replays — the reset is
// load-bearing because the server's dynamic table starts empty on
// every fresh session.
func TestPassiveProvider_CapabilityResetOnReconnect(t *testing.T) {
server, addr := newFakeLSPSocketServer(t, "tcp")
defer server.Close()
p := passiveProvider(t, "tcp", addr, false)
defer p.Close()
// First connection. Wire the handlers ensureClient would install,
// then complete the handshake.
require.NoError(t, p.EnsureClient(t.TempDir()))
require.Eventually(t, func() bool {
for _, m := range server.Methods() {
if m == "initialize" {
return true
}
}
return false
}, time.Second, 5*time.Millisecond)
// Push a dynamic registration from the server. The Provider's
// reverse-RPC handler will absorb it into dynamicCaps.
reg := RegistrationParams{
Registrations: []Registration{
{ID: "reg-1", Method: "textDocument/hover"},
},
}
require.NoError(t, server.PushRequest(1, "client/registerCapability", reg))
require.Eventually(t, func() bool {
p.capsMu.RLock()
_, ok := p.dynamicCaps["reg-1"]
p.capsMu.RUnlock()
return ok
}, time.Second, 5*time.Millisecond, "dynamic registration should have landed in dynamicCaps")
// Force a reconnect: drop the server-side connection, restart
// accept, then ensureClient again.
server.CloseConn()
go server.acceptLoop()
require.Eventually(t, func() bool {
select {
case <-p.client.Done():
return true
default:
return false
}
}, time.Second, 5*time.Millisecond)
require.NoError(t, p.EnsureClient(t.TempDir()))
// After reconnect, the dynamic table must be empty — the new
// connection has had no chance to re-register yet.
p.capsMu.RLock()
regs := len(p.dynamicCaps)
p.capsMu.RUnlock()
require.Equal(t, 0, regs, "dynamicCaps must be reset on reconnect, found %d entries", regs)
}
// TestPassiveProvider_ConnectSpecValidate covers the explicit
// validation surface — an empty connect block must be rejected with a
// clear error; a well-formed block must be accepted.
func TestPassiveProvider_ConnectSpecValidate(t *testing.T) {
cases := []struct {
name string
c *ConnectSpec
want string
}{
{
name: "both empty",
c: &ConnectSpec{Network: "", Address: ""},
want: "network and address are required",
},
{
name: "missing network",
c: &ConnectSpec{Network: "", Address: "127.0.0.1:7677"},
want: "network is required",
},
{
name: "missing address",
c: &ConnectSpec{Network: "tcp", Address: ""},
want: "address is required",
},
{
name: "bad network",
c: &ConnectSpec{Network: "wss", Address: "127.0.0.1:1"},
want: "unsupported network",
},
{
name: "tcp ok",
c: &ConnectSpec{Network: "tcp", Address: "127.0.0.1:7677"},
want: "",
},
{
name: "unix ok",
c: &ConnectSpec{Network: "unix", Address: "/tmp/lsp.sock"},
want: "",
},
{
name: "nil ok",
c: nil,
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.c.Validate()
if tc.want == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tc.want)
})
}
}
// TestPassiveConfig_LoadAcceptsConnectBlock confirms that the YAML
// loader binds a passive-attach block under
// `semantic.providers[*].connect` into the typed config struct.
func TestPassiveConfig_LoadAcceptsConnectBlock(t *testing.T) {
const yaml = `
semantic:
providers:
- name: gopls
enabled: true
languages: [go]
connect:
network: tcp
address: 127.0.0.1:7677
fallback_spawn: false
`
cfg := loadInlineConfig(t, yaml)
require.Len(t, cfg.Semantic.Providers, 1)
pc := cfg.Semantic.Providers[0]
require.Equal(t, "gopls", pc.Name)
require.NotNil(t, pc.Connect)
require.Equal(t, "tcp", pc.Connect.Network)
require.Equal(t, "127.0.0.1:7677", pc.Connect.Address)
require.False(t, pc.Connect.FallbackSpawn)
}
// TestPassiveConfig_LoadRejectsEmptyConnectBlock asserts that a
// `connect: {}` (both fields blank) is refused by the validation pass.
func TestPassiveConfig_LoadRejectsEmptyConnectBlock(t *testing.T) {
const yaml = `
semantic:
providers:
- name: gopls
enabled: true
languages: [go]
connect:
network: ""
address: ""
`
v := viper.New()
v.SetConfigType("yaml")
require.NoError(t, v.ReadConfig(strings.NewReader(yaml)))
cfg := config.Default()
require.NoError(t, v.Unmarshal(cfg))
// Trigger validation explicitly by re-using the published Load
// path — Default + Unmarshal does not validate; the load loop
// does. We call the test surface directly because Load reads
// from disk, not a string.
err := cfg.ValidateSemanticConnectForTest()
require.Error(t, err)
require.Contains(t, err.Error(), "network and address are required")
}
// loadInlineConfig parses a YAML string into a Config using the same
// viper machinery `config.Load` uses, then runs the same validation
// pass. Returns the populated Config on success; fails the test on
// either parse or validation error.
func loadInlineConfig(t *testing.T, yamlText string) *config.Config {
t.Helper()
v := viper.New()
v.SetConfigType("yaml")
require.NoError(t, v.ReadConfig(strings.NewReader(yamlText)))
cfg := config.Default()
require.NoError(t, v.Unmarshal(cfg))
require.NoError(t, cfg.ValidateSemanticConnectForTest())
return cfg
}