From 1b41e43bf322af3b6dc0e60513b9dbcba78019ea Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 19 Jun 2026 22:47:14 +0100 Subject: [PATCH] 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. --- internal/lsp/client.go | 31 +++++++++++++++++++++++++++++-- internal/lsp/client_test.go | 28 ++++++++++++++++++++++++++++ internal/lsp/manager.go | 8 +++++++- 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 internal/lsp/client_test.go diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 9d93b02..822916a 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -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() diff --git a/internal/lsp/client_test.go b/internal/lsp/client_test.go new file mode 100644 index 0000000..f5b8a1e --- /dev/null +++ b/internal/lsp/client_test.go @@ -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) + } +} diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 2db0309..4ded9d9 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -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