package diag
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// idleTimeout is how long a language server may sit with no file to check
// before it is shut down. Five minutes is long enough that a burst of edits
// shares one server and short enough that a session which has moved on to
// another language is not carrying gopls's memory around for the rest of the
// afternoon.
const idleTimeout = 5 * time.Minute
// initTimeout is how long a server has to answer initialize. Every server in
// the table answers it in well under a second and does its real loading
// afterwards, so a server that has not answered in thirty seconds is one that
// never will — a broken shim, a binary for another architecture — and every
// check against it would otherwise pay the full checkTimeout for nothing.
const initTimeout = 30 * time.Second
// shutdownGrace is how long Close waits for a server to answer shutdown and
// then to exit before it is killed. A server that has not managed either in
// half a second is not going to.
const shutdownGrace = 500 * time.Millisecond
// Limits on what a server may make the client hold. A server that publishes
// for thousands of files or thousands of problems in one file is one whose
// output the model could not use anyway, and holding all of it would let a
// misbehaving server grow the harness without bound.
const (
maxDiagnosticFiles = 50
maxDiagnosticsPerURI = 200
)
// errServerDead is what a check gets from a server that has exited or been
// closed. The Runner drops such a server from its table so the next check
// starts a fresh one.
var errServerDead = errors.New("language server is not running")
// session is one running language server and the client side of its
// connection. It lives across edits: starting gopls costs seconds, and paying
// that per edit would put every check past its deadline.
type session struct {
name string
spec serverSpec
root string
// stdin is written under writeMu so requests from Check and replies from
// the reader never interleave.
writeMu sync.Mutex
stdin io.WriteCloser
// kill ends the process; wait reaps it. Both are no-ops for the in-process
// fake the tests use.
kill func()
wait func()
// ready is closed once initialize has been answered; initErr says whether
// it went well. dead is closed when the connection is gone for any reason.
ready chan struct{}
initErr error
dead chan struct{}
dieOnce sync.Once
onExit func(*session)
// busy is a one-slot semaphore: one check at a time per server, because
// a collector is attached to the whole connection and two checks would
// steal each other's batches. It is a channel rather than a mutex so a
// waiting check can give up when its context does.
busy chan struct{}
mu sync.Mutex
nextID int64
pending map[int64]chan rpcResponse
versions map[string]int
collector *collector
idle *time.Timer
idleD time.Duration
closed bool
// caps is what the server said it can do in its initialize result, keyed
// by capability name, so a query the server never offered is refused here
// rather than sent and answered with an error whose wording varies by
// server. Written once before ready is closed and read only after.
caps map[string]json.RawMessage
}
// rpcResponse is the part of a response the client uses.
type rpcResponse struct {
Result json.RawMessage
Error *rpcError
}
// rpcError is a JSON-RPC error object.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string { return fmt.Sprintf("%s (%d)", e.Message, e.Code) }
// startServer spawns bin in root and begins initializing it. It returns nil if
// the process could not be started. Initialization continues in the
// background; Check waits for it under the caller's deadline, so a slow server
// costs one missed check rather than a hung one.
func startServer(bin string, spec serverSpec, root string, idle time.Duration, onExit func(*session)) *session {
cmd := exec.Command(bin, spec.Args...)
cmd.Dir = root
// stderr is where servers log. It is discarded rather than captured
// because gopls alone writes megabytes of it over a session, and nobody
// reads a language server's log from inside a coding agent.
cmd.Stderr = nil
stdin, err := cmd.StdinPipe()
if err != nil {
return nil
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil
}
if err := cmd.Start(); err != nil {
return nil
}
var waitOnce sync.Once
s := newSession(spec, root, stdin, stdout,
func() { _ = cmd.Process.Kill() },
func() { waitOnce.Do(func() { _ = cmd.Wait() }) },
idle, onExit)
return s
}
// newSession wires a session over an already-open connection and starts the
// reader and the initialize handshake. It is the seam the tests use to speak
// to an in-process fake server through pipes.
func newSession(spec serverSpec, root string, stdin io.WriteCloser, stdout io.Reader, kill, wait func(), idle time.Duration, onExit func(*session)) *session {
if kill == nil {
kill = func() {}
}
if wait == nil {
wait = func() {}
}
if onExit == nil {
onExit = func(*session) {}
}
s := &session{
name: filepath.Base(spec.Bin),
spec: spec,
root: root,
stdin: stdin,
kill: kill,
wait: wait,
ready: make(chan struct{}),
dead: make(chan struct{}),
onExit: onExit,
busy: make(chan struct{}, 1),
pending: map[int64]chan rpcResponse{},
versions: map[string]int{},
idleD: idle,
}
if idle > 0 {
s.idle = time.AfterFunc(idle, s.idleClose)
}
go s.readLoop(bufio.NewReaderSize(stdout, 64<<10))
go s.initialize()
return s
}
// Dead reports whether the connection is gone.
func (s *session) Dead() bool {
select {
case <-s.dead:
return true
default:
return false
}
}
// acquire takes the server's one request slot and then waits for the server
// to be ready, giving up when ctx does or the server dies. The slot is taken
// first so that a request arriving while the server is still initializing
// queues behind it rather than piling up several waiters that all wake at
// once. The returned release must be called exactly once.
func (s *session) acquire(ctx context.Context) (release func(), err error) {
select {
case s.busy <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
case <-s.dead:
return nil, errServerDead
}
release = func() {
s.touch()
<-s.busy
}
s.touch()
select {
case <-s.ready:
case <-ctx.Done():
release()
return nil, ctx.Err()
case <-s.dead:
release()
return nil, errServerDead
}
if s.initErr != nil {
release()
return nil, s.initErr
}
return release, nil
}
// Check sends the file to the server and collects what it publishes about it.
func (s *session) Check(ctx context.Context, path string) ([]Diagnostic, error) {
release, err := s.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
uri := fileURI(path)
c := newCollector(uri)
s.setCollector(c)
defer s.setCollector(nil)
if err := s.sendDocument(uri, path, string(content)); err != nil {
return nil, err
}
// First batch for the changed file, then a short window for the rest —
// see settleWindow. If the window is cut short by the deadline what has
// arrived so far is still returned; it is real.
select {
case <-c.got:
case <-ctx.Done():
return nil, ctx.Err()
case <-s.dead:
return nil, errServerDead
}
settle := time.NewTimer(settleWindow)
defer settle.Stop()
select {
case <-settle.C:
case <-ctx.Done():
case <-s.dead:
}
return c.result(s.name), nil
}
// Close asks the server to stop and makes sure it has. Safe to call more than
// once and concurrently with a check, which will see errServerDead.
func (s *session) Close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
if s.idle != nil {
s.idle.Stop()
}
s.mu.Unlock()
if !s.Dead() {
// The polite sequence, each step bounded. Skipping straight to kill
// would work, but gopls left uncleanly leaves stale state in its
// cache directory that slows its next start.
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
_, _ = s.call(ctx, "shutdown", nil)
cancel()
_ = s.notify("exit", nil)
_ = s.stdin.Close()
select {
case <-s.dead:
case <-time.After(shutdownGrace):
}
}
s.die(errServerDead)
s.wait()
}
// idleClose is the idle timer's callback. A check that is in flight keeps
// the server: the timer is pushed back rather than the check interrupted. The
// check slot is held through Close so a check arriving during shutdown waits
// and then finds the server dead, rather than talking to it halfway down.
func (s *session) idleClose() {
select {
case s.busy <- struct{}{}:
default:
s.touch()
return
}
defer func() { <-s.busy }()
s.Close()
}
// failedToStart reports whether the server died or was closed before it had
// finished initializing. The Runner uses it to stop retrying a server that
// is installed but does not work.
func (s *session) failedToStart() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.initErr != nil
}
// touch pushes the idle deadline back to now plus the idle timeout.
func (s *session) touch() {
s.mu.Lock()
defer s.mu.Unlock()
if s.idle != nil && !s.closed {
s.idle.Reset(s.idleD)
}
}
// die marks the connection gone, fails every request still waiting on it,
// and ends the process. Everything that can end a session funnels through
// here so it happens once.
func (s *session) die(err error) {
s.dieOnce.Do(func() {
s.mu.Lock()
if s.initErr == nil && !s.isReady() {
s.initErr = err
}
pending := s.pending
s.pending = map[int64]chan rpcResponse{}
if s.idle != nil {
s.idle.Stop()
}
s.mu.Unlock()
for _, ch := range pending {
close(ch)
}
close(s.dead)
s.readyOnce()
s.kill()
_ = s.stdin.Close()
go func() {
s.wait()
s.onExit(s)
}()
})
}
func (s *session) isReady() bool {
select {
case <-s.ready:
return true
default:
return false
}
}
// readyOnce closes ready if it is not already closed.
func (s *session) readyOnce() {
select {
case <-s.ready:
default:
close(s.ready)
}
}
// initialize runs the handshake. It has no deadline of its own: a server
// still loading a large module after the first check's two seconds is a
// server worth waiting for, and the checks are what carry the deadline. If
// the process dies the reader ends it.
func (s *session) initialize() {
rootURI := fileURI(s.root)
params := map[string]any{
"processId": os.Getpid(),
"clientInfo": map[string]any{
"name": "codey",
},
"rootUri": rootURI,
"rootPath": s.root,
"workspaceFolders": []map[string]any{{
"uri": rootURI,
"name": filepath.Base(s.root),
}},
"capabilities": map[string]any{
"textDocument": map[string]any{
"publishDiagnostics": map[string]any{
"relatedInformation": false,
},
"synchronization": map[string]any{
"dynamicRegistration": false,
"didSave": true,
},
// Markdown first: it is what every server in the table
// produces natively, and its code fences are how the
// signature is told apart from the doc comment when the
// hover is trimmed for the model.
"hover": map[string]any{
"contentFormat": []string{"markdown", "plaintext"},
},
// With hierarchy a struct's methods and fields come nested
// under it, which is what lets the outline name the
// container; without it gopls sends a flat list of top-level
// names only.
"documentSymbol": map[string]any{
"hierarchicalDocumentSymbolSupport": true,
},
},
"workspace": map[string]any{
"workspaceFolders": true,
"configuration": false,
},
"window": map[string]any{
"workDoneProgress": false,
},
},
"initializationOptions": map[string]any{},
}
ctx, cancel := context.WithTimeout(context.Background(), initTimeout)
defer cancel()
result, err := s.call(ctx, "initialize", params)
if err != nil {
s.mu.Lock()
s.initErr = fmt.Errorf("%s: initialize: %w", s.name, err)
s.mu.Unlock()
s.readyOnce()
s.die(s.initErr)
return
}
var init struct {
Capabilities map[string]json.RawMessage `json:"capabilities"`
}
// A result that does not parse is not fatal: the server is up and can
// still check files, which is the more important job. It just gets no
// queries.
_ = json.Unmarshal(result, &init)
s.caps = init.Capabilities
if err := s.notify("initialized", map[string]any{}); err != nil {
s.mu.Lock()
s.initErr = fmt.Errorf("%s: initialized: %w", s.name, err)
s.mu.Unlock()
s.readyOnce()
s.die(s.initErr)
return
}
s.readyOnce()
}
// sendDocument opens the file on the server or, if it is already open,
// replaces its content. Documents stay open across checks: a didClose after
// every check would have the server clear and recompute the file's
// diagnostics twice per edit, and some servers publish the clear late enough
// to be mistaken for the next check's answer. A didSave follows because the
// file genuinely is on disk, and rust-analyzer runs its cargo check only on
// save.
func (s *session) sendDocument(uri, path, content string) error {
s.mu.Lock()
version, open := s.versions[uri]
version++
s.versions[uri] = version
s.mu.Unlock()
var err error
if open {
err = s.notify("textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": uri, "version": version},
"contentChanges": []map[string]any{
{"text": content},
},
})
} else {
err = s.notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri,
"languageId": s.spec.languageID(path),
"version": version,
"text": content,
},
})
}
if err != nil {
return err
}
return s.notify("textDocument/didSave", map[string]any{
"textDocument": map[string]any{"uri": uri},
})
}
// call sends a request and waits for its response or for ctx.
func (s *session) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
s.mu.Lock()
s.nextID++
id := s.nextID
ch := make(chan rpcResponse, 1)
if s.Dead() {
s.mu.Unlock()
return nil, errServerDead
}
s.pending[id] = ch
s.mu.Unlock()
msg := map[string]any{"jsonrpc": "2.0", "id": id, "method": method}
if params != nil {
msg["params"] = params
}
if err := s.write(msg); err != nil {
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
select {
case resp, ok := <-ch:
if !ok {
return nil, errServerDead
}
if resp.Error != nil {
return nil, resp.Error
}
return resp.Result, nil
case <-ctx.Done():
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
return nil, ctx.Err()
}
}
// notify sends a notification: a request with no id and no reply.
func (s *session) notify(method string, params any) error {
msg := map[string]any{"jsonrpc": "2.0", "method": method}
if params != nil {
msg["params"] = params
}
return s.write(msg)
}
// reply answers a request the server made of us. It writes from its own
// goroutine rather than from the read loop that called it: a server that
// sends several requests in a row is blocked writing the second while we are
// blocked writing the answer to the first, and with both sides waiting on
// the other neither pipe drains. The OS pipe buffer hides this until a
// message is larger than it, which is exactly when it would be found in
// production and not before.
func (s *session) reply(id json.RawMessage, result any, err *rpcError) {
msg := map[string]any{"jsonrpc": "2.0", "id": id}
if err != nil {
msg["error"] = err
} else {
msg["result"] = result
}
go func() { _ = s.write(msg) }()
}
func (s *session) write(msg any) error {
if s.Dead() {
return errServerDead
}
body, err := json.Marshal(msg)
if err != nil {
return err
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
if err := writeMessage(s.stdin, body); err != nil {
// A write that fails is a pipe with nobody on the other end.
s.die(fmt.Errorf("%s: write: %w", s.name, err))
return errServerDead
}
return nil
}
// incoming is the superset of the three JSON-RPC message shapes; which one
// it is depends on which fields are set.
type incoming struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
// readLoop reads messages until the connection fails, dispatching responses
// to their callers and diagnostics to the collector. Any framing or JSON
// error ends the session: a stream that has lost sync cannot be recovered
// and a server we cannot understand is a server to restart.
func (s *session) readLoop(r *bufio.Reader) {
for {
body, err := readMessage(r)
if err != nil {
if err == io.EOF {
s.die(fmt.Errorf("%s exited", s.name))
} else {
s.die(fmt.Errorf("%s: %w", s.name, err))
}
return
}
var msg incoming
if err := json.Unmarshal(body, &msg); err != nil {
s.die(fmt.Errorf("%s sent invalid JSON: %w", s.name, err))
return
}
switch {
case msg.Method == "" && len(msg.ID) > 0:
s.dispatchResponse(msg)
case msg.Method != "" && len(msg.ID) > 0 && string(msg.ID) != "null":
s.handleRequest(msg)
case msg.Method != "":
s.handleNotification(msg)
}
}
}
func (s *session) dispatchResponse(msg incoming) {
var id int64
if err := json.Unmarshal(msg.ID, &id); err != nil {
return
}
s.mu.Lock()
ch, ok := s.pending[id]
delete(s.pending, id)
s.mu.Unlock()
if ok {
ch <- rpcResponse{Result: msg.Result, Error: msg.Error}
}
}
// handleRequest answers what servers ask of a client. Each gets the emptiest
// legal answer: this client has no UI to show progress in, no settings to
// hand over, and registers no capabilities dynamically. Not answering at all
// is not an option — gopls waits on workspace/configuration before it will
// type-check anything.
func (s *session) handleRequest(msg incoming) {
switch msg.Method {
case "workspace/configuration":
var p struct {
Items []json.RawMessage `json:"items"`
}
_ = json.Unmarshal(msg.Params, &p)
s.reply(msg.ID, make([]any, len(p.Items)), nil)
case "client/registerCapability", "client/unregisterCapability",
"window/workDoneProgress/create", "workspace/applyEdit":
s.reply(msg.ID, nil, nil)
case "window/showMessageRequest":
s.reply(msg.ID, nil, nil)
case "workspace/workspaceFolders":
s.reply(msg.ID, []map[string]any{{"uri": fileURI(s.root), "name": filepath.Base(s.root)}}, nil)
default:
s.reply(msg.ID, nil, &rpcError{Code: -32601, Message: "method not found"})
}
}
func (s *session) handleNotification(msg incoming) {
if msg.Method != "textDocument/publishDiagnostics" {
return
}
var p struct {
URI string `json:"uri"`
Diagnostics []struct {
Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
Severity int `json:"severity"`
Message string `json:"message"`
} `json:"diagnostics"`
}
if err := json.Unmarshal(msg.Params, &p); err != nil {
return
}
path := uriPath(p.URI)
if path == "" {
return
}
ds := make([]Diagnostic, 0, min(len(p.Diagnostics), maxDiagnosticsPerURI))
for _, d := range p.Diagnostics {
if len(ds) == maxDiagnosticsPerURI {
break
}
sev := Severity(d.Severity)
if sev == 0 {
// The spec lets a server omit severity, and says the client
// should then interpret it as it likes. An unqualified
// complaint from a compiler is an error.
sev = SeverityError
}
ds = append(ds, Diagnostic{
File: path,
Line: d.Range.Start.Line + 1,
Col: d.Range.Start.Character + 1,
Severity: sev,
Message: d.Message,
})
}
s.mu.Lock()
c := s.collector
s.mu.Unlock()
if c != nil {
c.add(p.URI, path, ds)
}
}
func (s *session) setCollector(c *collector) {
s.mu.Lock()
s.collector = c
s.mu.Unlock()
}
// collector gathers publishDiagnostics batches during one check. The latest
// batch per file wins, because a server that publishes twice for a file is
// replacing, not adding.
type collector struct {
target string
targetPath string
got chan struct{}
mu sync.Mutex
byPath map[string][]Diagnostic
}
func newCollector(target string) *collector {
return &collector{
target: target,
targetPath: uriPath(target),
got: make(chan struct{}, 1),
byPath: map[string][]Diagnostic{},
}
}
// add records a batch. The file being checked is always kept; neighbours are
// kept until the file cap is reached, so a server that publishes for the
// whole workspace cannot push out the one file the model asked about.
func (c *collector) add(uri, path string, ds []Diagnostic) {
c.mu.Lock()
_, seen := c.byPath[path]
if seen || path == c.targetPath || len(c.byPath) < maxDiagnosticFiles {
c.byPath[path] = ds
}
c.mu.Unlock()
if sameURI(uri, c.target) {
select {
case c.got <- struct{}{}:
default:
}
}
}
// result flattens what was collected and stamps it with the server's name.
func (c *collector) result(source string) []Diagnostic {
c.mu.Lock()
defer c.mu.Unlock()
var out []Diagnostic
for _, ds := range c.byPath {
for _, d := range ds {
d.Source = source
out = append(out, d)
}
}
return out
}
// fileURI is path as a file: URI, with the characters a URI cannot carry
// escaped. url.URL does the escaping; building the string by hand would break
// on the first path with a space in it.
func fileURI(path string) string {
path = filepath.ToSlash(path)
if runtime.GOOS == "windows" && !strings.HasPrefix(path, "/") {
path = "/" + path
}
u := url.URL{Scheme: "file", Path: path}
return u.String()
}
// uriPath is the inverse of fileURI, or "" for a URI that is not a file.
func uriPath(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.Scheme != "file" {
return ""
}
p := u.Path
if runtime.GOOS == "windows" && len(p) > 2 && p[0] == '/' && p[2] == ':' {
p = p[1:]
}
return filepath.Clean(filepath.FromSlash(p))
}
// sameURI compares two file URIs by the path they name, so a server that
// percent-encodes differently from us still matches.
func sameURI(a, b string) bool {
if a == b {
return true
}
pa, pb := uriPath(a), uriPath(b)
return pa != "" && pa == pb
}