mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
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:
+29
-2
@@ -22,6 +22,10 @@ const ProcessKillTimeout = 5 * time.Second
|
|||||||
// StderrBufferSize is the buffer size for draining stderr.
|
// StderrBufferSize is the buffer size for draining stderr.
|
||||||
const StderrBufferSize = 1024
|
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.
|
// Client represents an LSP client connection.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
@@ -35,6 +39,8 @@ type Client struct {
|
|||||||
runningMu sync.RWMutex
|
runningMu sync.RWMutex
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
stderrMu sync.Mutex
|
||||||
|
stderrTail []byte // most recent stderr, capped at StderrCaptureMax (diagnostics)
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +141,35 @@ func (c *Client) drainStderr() {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
// Read and discard stderr output
|
n, err := c.stderr.Read(buf)
|
||||||
_, err := c.stderr.Read(buf)
|
if n > 0 {
|
||||||
|
c.captureStderr(buf[:n])
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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.
|
// Call sends a request and waits for a response.
|
||||||
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
||||||
c.runningMu.RLock()
|
c.runningMu.RLock()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,10 +212,16 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
|||||||
// Initialize server
|
// Initialize server
|
||||||
if err := m.initializeServer(ctx, newSrv); err != nil {
|
if err := m.initializeServer(ctx, newSrv); err != nil {
|
||||||
newSrv.initErr = err
|
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("language", string(lang)).
|
||||||
WithContext("command", config.Command[0]).
|
WithContext("command", config.Command[0]).
|
||||||
WithRemediation("Check LSP server logs for initialization errors")
|
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
|
// Mark as successfully initialized to prevent cleanup
|
||||||
|
|||||||
Reference in New Issue
Block a user