feat(lsp): capture server stderr to diagnose init failures

drainStderr discarded all server stderr, so an LSP init failure (e.g. 'command not found', missing module) surfaced as an opaque 'initialization failed'. Retain a bounded tail (StderrCaptureMax=4096B, oldest dropped) under a mutex and attach it to the init-failure error context. Adds TestClientStderrCapture.
This commit is contained in:
2026-06-20 00:33:33 +01:00
parent 13b1a22102
commit 1b41e43bf3
3 changed files with 64 additions and 3 deletions
+29 -2
View File
@@ -22,6 +22,10 @@ const ProcessKillTimeout = 5 * time.Second
// StderrBufferSize is the buffer size for draining stderr.
const StderrBufferSize = 1024
// StderrCaptureMax is the maximum number of recent stderr bytes retained for
// diagnostics (e.g. to explain an init failure). Older bytes are dropped.
const StderrCaptureMax = 4096
// Client represents an LSP client connection.
type Client struct {
stdin io.WriteCloser
@@ -35,6 +39,8 @@ type Client struct {
runningMu sync.RWMutex
stopOnce sync.Once
mu sync.Mutex
stderrMu sync.Mutex
stderrTail []byte // most recent stderr, capped at StderrCaptureMax (diagnostics)
running bool
}
@@ -135,14 +141,35 @@ func (c *Client) drainStderr() {
return
default:
}
// Read and discard stderr output
_, err := c.stderr.Read(buf)
n, err := c.stderr.Read(buf)
if n > 0 {
c.captureStderr(buf[:n])
}
if err != nil {
return
}
}
}
// captureStderr appends p to the retained stderr tail, keeping at most
// StderrCaptureMax bytes (dropping the oldest).
func (c *Client) captureStderr(p []byte) {
c.stderrMu.Lock()
defer c.stderrMu.Unlock()
c.stderrTail = append(c.stderrTail, p...)
if len(c.stderrTail) > StderrCaptureMax {
c.stderrTail = c.stderrTail[len(c.stderrTail)-StderrCaptureMax:]
}
}
// RecentStderr returns the retained tail of the server's stderr, trimmed. Useful
// for surfacing why an LSP server failed to start.
func (c *Client) RecentStderr() string {
c.stderrMu.Lock()
defer c.stderrMu.Unlock()
return strings.TrimSpace(string(c.stderrTail))
}
// Call sends a request and waits for a response.
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
c.runningMu.RLock()
+28
View File
@@ -0,0 +1,28 @@
package lsp
import (
"strings"
"testing"
)
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
// must keep recent bytes and never grow past StderrCaptureMax.
func TestClientStderrCapture(t *testing.T) {
c := &Client{}
if c.RecentStderr() != "" {
t.Errorf("expected empty stderr initially, got %q", c.RecentStderr())
}
c.captureStderr([]byte("gopls: command not found\n"))
if !strings.Contains(c.RecentStderr(), "command not found") {
t.Errorf("expected captured stderr, got %q", c.RecentStderr())
}
// Exceed the cap: only the most recent StderrCaptureMax bytes are kept.
big := strings.Repeat("x", StderrCaptureMax*2)
c.captureStderr([]byte(big))
if got := len(c.RecentStderr()); got > StderrCaptureMax {
t.Errorf("stderr tail not bounded: len=%d, want <= %d", got, StderrCaptureMax)
}
}
+7 -1
View File
@@ -212,10 +212,16 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
// Initialize server
if err := m.initializeServer(ctx, newSrv); err != nil {
newSrv.initErr = err
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
wrapped := errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
WithContext("language", string(lang)).
WithContext("command", config.Command[0]).
WithRemediation("Check LSP server logs for initialization errors")
// Surface the server's own stderr (e.g. "command not found", missing
// module) so the failure is diagnosable instead of opaque.
if stderr := client.RecentStderr(); stderr != "" {
wrapped = wrapped.WithContext("stderr", stderr)
}
return nil, wrapped
}
// Mark as successfully initialized to prevent cleanup