Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tools/lsp/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Available Capabilities:

LSP toolsets are managed by the same supervisor as MCP toolsets, so a crashed `gopls` (or any other language server) is reconnected automatically with exponential backoff. Use the [`lifecycle`](../../configuration/tools/index.md#toolset-lifecycle) block to tune the policy per toolset β€” for example, mark `gopls` as `strict` if your CI flow requires it to be available, or use `/toolset-restart gopls` from the TUI to force a reconnect when the server gets stuck.

**Startup failure behaviour:** missing-binary and bad-config failures fail fast β€” each turn retries immediately with no artificial delay. A language server that crash-loops is not currently paced by the backoff gate; the supervisor's own reconnect policy (controlled by the `lifecycle` block) is the primary throttle for crash recovery.
**Startup failure behaviour:** missing-binary and bad-config failures fail fast β€” each turn retries immediately with no artificial delay. A language server that crash-loops (3 crashes within 1 minute, by default) is different: the supervisor stops auto-restarting and reports the loop instead. The next attempt to use the toolset (a turn's start, a tool call, or `/toolset-restart`) surfaces that report rather than relaunching the server, and the backoff gate then paces subsequent attempts (15s, doubling up to 5 minutes) the same way it paces a rate-limited MCP server β€” so a server that dies right after every restart no longer relaunches at full speed. This applies regardless of `profile`: even a `strict` toolset is retried on the next turn once its window elapses, rather than staying down until an explicit `/toolset-restart`.

```yaml
toolsets:
Expand Down
31 changes: 28 additions & 3 deletions pkg/tools/builtin/lsp/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ type ToolSet struct {

// Verify interface compliance
var (
_ tools.ToolSet = (*ToolSet)(nil)
_ tools.Startable = (*ToolSet)(nil)
_ tools.Instructable = (*ToolSet)(nil)
_ tools.ToolSet = (*ToolSet)(nil)
_ tools.Startable = (*ToolSet)(nil)
_ tools.Instructable = (*ToolSet)(nil)
_ tools.StartReporter = (*ToolSet)(nil)
)

type lspHandler struct {
Expand Down Expand Up @@ -431,6 +432,20 @@ func (t *ToolSet) Stop(ctx context.Context) error {
return t.handler.supervisor.Stop(ctx)
}

// IsStarted implements tools.StartReporter: reports whether the supervisor
// requires external action (Start/Restart) to serve requests again.
// Deliberately looser than the MCP toolset's IsStarted (which tracks
// Ready/Degraded only): a transient Restarting still reports true here,
// since the supervisor already self-heals a one-off crash on its own
// watcher goroutine and every per-request call (ensureInitialized) retries
// eagerly regardless. Only a give-up β€” Failed (crash loop, exhausted
// restarts) or Stopped β€” reports false, so StartableToolSet gets involved
// (via Restart) exactly when the supervisor needs a caller-paced retry, not
// on every ordinary transient reconnect.
func (t *ToolSet) IsStarted() bool {
return !t.handler.supervisor.State().State.IsTerminal()
}

// State returns a snapshot of the underlying supervisor's lifecycle state,
// suitable for the /tools dialog and lifecycle log messages.
func (t *ToolSet) State() lifecycle.StateInfo {
Expand Down Expand Up @@ -712,7 +727,17 @@ func (h *lspHandler) ensureInitialized(ctx context.Context) error {

// Lazy-start through the supervisor. Concurrent ensureInitialized
// callers serialize inside Supervisor.Start.
//
// A pending crash-loop report is checked first and, if present,
// returned as-is without calling Start: this per-request path bypasses
// StartableToolSet's backoff gate entirely (it isn't the wrapper's
// paced TryStart), so it must not be the one to consume β€” and thereby
// reconnect on behalf of β€” a one-shot report meant for the gate to
// pace. Only the gate's own eventual Start call clears it.
if !h.supervisor.IsReady() {
if err := h.supervisor.PendingCrashLoopError(); err != nil {
return fmt.Errorf("failed to start LSP server: %w", err)
}
if err := h.supervisor.Start(ctx); err != nil {
return fmt.Errorf("failed to start LSP server: %w", err)
}
Expand Down
230 changes: 230 additions & 0 deletions pkg/tools/builtin/lsp/lsp_crashloop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package lsp

import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/lifecycle"
)

// fakeLSPServerEnv names the environment variable that tells this test
// binary, when re-executed as a subprocess, to behave as a fake LSP server
// instead of running the Go test suite. Its value is the path of a log
// file the fake server appends one line to on every spawn, so tests can
// count how many times the supervisor actually spawned a new process.
const fakeLSPServerEnv = "DOCKER_AGENT_LSP_TEST_FAKE_SERVER_LOG"

// TestMain lets this test binary re-exec itself (os.Args[0]) as a fake LSP
// server: exec.Command needs a real, portable executable, and the test
// binary itself is the simplest one available on every platform CI runs on.
func TestMain(m *testing.M) {
if logPath := os.Getenv(fakeLSPServerEnv); logPath != "" {
runFakeCrashingLSPServer(logPath)
return // unreachable: runFakeCrashingLSPServer always calls os.Exit.
}
os.Exit(m.Run())
}

// runFakeCrashingLSPServer answers the initialize/initialized handshake
// exactly once, records that it ran, then exits non-zero to simulate a
// crash right after startup β€” every time it is spawned.
func runFakeCrashingLSPServer(logPath string) {
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
fmt.Fprintln(f, "spawn")
_ = f.Close()
}

r := bufio.NewReader(os.Stdin)
if body, err := readFramedMessage(r); err == nil {
var req struct {
ID int64 `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal(body, &req) == nil && req.Method == "initialize" {
resp, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{"capabilities": map[string]any{}},
})
if writeFramedMessage(os.Stdout, resp) == nil {
_, _ = readFramedMessage(r) // the "initialized" notification; ignored.
}
}
}
os.Exit(1)
}

// readFramedMessage and writeFramedMessage mirror the Content-Length
// framing lspHandler itself speaks (see readMessageLocked/writeMessageLocked).
func readFramedMessage(r *bufio.Reader) ([]byte, error) {
contentLength := 0
for {
line, err := r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" {
break
}
if after, ok := strings.CutPrefix(line, "Content-Length:"); ok {
contentLength, err = strconv.Atoi(strings.TrimSpace(after))
if err != nil {
return nil, err
}
}
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
return body, nil
}

func writeFramedMessage(w io.Writer, data []byte) error {
if _, err := fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data)); err != nil {
return err
}
_, err := w.Write(data)
return err
}

// countSpawns counts lines in the fake server's spawn log, i.e. how many
// times it has actually been launched as a subprocess.
func countSpawns(t *testing.T, path string) int {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return 0
}
t.Fatalf("failed to read spawn log: %v", err)
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return 0
}
return len(strings.Split(trimmed, "\n"))
}

// TestLSPTool_CrashLoopArmsBackoffGate drives a real ToolSet, wrapped in
// tools.StartableToolSet exactly as production wires it, against a fake
// LSP server that completes the handshake and then exits non-zero every
// time it is spawned. It proves the whole chain end to end: a sustained
// crash loop stops the supervisor's own auto-restart, TryStart surfaces
// lifecycle.ErrCrashLooping, and the backoff gate then withholds further
// spawns until its window elapses.
func TestLSPTool_CrashLoopArmsBackoffGate(t *testing.T) {
t.Parallel()

spawnLog := filepath.Join(t.TempDir(), "spawns.log")
env := []string{fakeLSPServerEnv + "=" + spawnLog}

policy := lifecycle.Policy{
Backoff: lifecycle.Backoff{Initial: 2 * time.Millisecond, Max: 5 * time.Millisecond, Multiplier: 2},
CrashLoop: lifecycle.CrashLoop{Threshold: 3, Window: time.Minute},
}
tool := New(os.Args[0], nil, env, t.TempDir(), policy)
s := tools.NewStartable(tool, tools.WithStartRetryJitter(func(d time.Duration) time.Duration { return d }))
t.Cleanup(func() { _ = s.Stop(t.Context()) })

started, err := s.TryStart(t.Context())
require.NoError(t, err)
require.True(t, started)

// The fake server crashes right after the handshake every time; wait
// for the crash-loop detector to give up (state -> Failed) rather than
// racing the background watcher's own restart attempts.
require.Eventually(t, func() bool {
return tool.State().State == lifecycle.StateFailed
}, 10*time.Second, 5*time.Millisecond, "supervisor did not detect the crash loop")

spawnsAtLoop := countSpawns(t, spawnLog)
assert.Equal(t, 3, spawnsAtLoop, "the loop must trip at exactly CrashLoop.Threshold spawns, no more")

// The next TryStart reports the loop instead of relaunching.
_, err = s.TryStart(t.Context())
require.ErrorIs(t, err, lifecycle.ErrCrashLooping)
assert.Equal(t, spawnsAtLoop, countSpawns(t, spawnLog), "the crash-loop report must not itself spawn a server")

// Gate now armed: an immediate retry must not spawn either.
_, err = s.TryStart(t.Context())
require.Error(t, err)
assert.Equal(t, spawnsAtLoop, countSpawns(t, spawnLog), "gate must withhold the next spawn until its window elapses")
}

// TestLSPTool_CrashLoopPendingReportBlocksDirectToolCalls verifies that a
// tool call reaching the LSP handler while a crash-loop report is pending
// β€” ensureInitialized's lazy per-request path, which bypasses
// tools.StartableToolSet entirely β€” fails fast on the same error without
// spawning a new server, and without consuming the one-shot report: only
// the wrapper's own paced retry (Start, once its backoff window elapses)
// may do that.
func TestLSPTool_CrashLoopPendingReportBlocksDirectToolCalls(t *testing.T) {
t.Parallel()

spawnLog := filepath.Join(t.TempDir(), "spawns.log")
env := []string{fakeLSPServerEnv + "=" + spawnLog}

policy := lifecycle.Policy{
Backoff: lifecycle.Backoff{Initial: 2 * time.Millisecond, Max: 5 * time.Millisecond, Multiplier: 2},
CrashLoop: lifecycle.CrashLoop{Threshold: 3, Window: time.Minute},
}
tool := New(os.Args[0], nil, env, t.TempDir(), policy)
t.Cleanup(func() { _ = tool.Stop(t.Context()) })

require.NoError(t, tool.Start(t.Context()))

require.Eventually(t, func() bool {
return tool.State().State == lifecycle.StateFailed
}, 10*time.Second, 5*time.Millisecond, "supervisor did not detect the crash loop")

spawnsAtLoop := countSpawns(t, spawnLog)
assert.Equal(t, 3, spawnsAtLoop)

// ensureInitialized has a fast path keyed on the atomic `initialized`
// flag, which a raw crash (detected only in the background watcher)
// does not clear β€” only a fresh Connect or an explicit Close does. That
// pre-existing gap (independent of crash-loop pacing; it also affects
// the ordinary exhausted-restart give-up) means a tool call arriving
// immediately after this crash would still see the stale flag and skip
// the check below entirely. Clear it here to exercise the check as it
// would run once that flag correctly reflects the disconnect.
tool.handler.initialized.Store(false)

// A tool call arriving now goes through ensureInitialized, not
// StartableToolSet.TryStart. It must see the same error and must not
// spawn a server on its own.
err := tool.handler.ensureInitialized(t.Context())
require.ErrorIs(t, err, lifecycle.ErrCrashLooping)
assert.Equal(t, spawnsAtLoop, countSpawns(t, spawnLog), "a direct tool call must not spawn while a crash-loop report is pending")

// The report must still be pending for the next caller: a direct call
// must not have consumed it.
require.ErrorIs(t, tool.handler.supervisor.PendingCrashLoopError(), lifecycle.ErrCrashLooping)

// Only the "real" gated caller (standing in for StartableToolSet's
// paced Restart/Start once its window elapses) may consume it and
// reconnect for real: the very next Start still reports it once
// (ensureInitialized's peek above did not consume it), and the Start
// after that performs the genuine reconnect.
err = tool.Start(t.Context())
require.ErrorIs(t, err, lifecycle.ErrCrashLooping)
assert.Equal(t, spawnsAtLoop, countSpawns(t, spawnLog), "the consuming Start must not itself spawn either")

require.NoError(t, tool.Start(t.Context()))
assert.Equal(t, spawnsAtLoop+1, countSpawns(t, spawnLog))
assert.NoError(t, tool.handler.supervisor.PendingCrashLoopError())
}
11 changes: 11 additions & 0 deletions pkg/tools/lifecycle/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ var (
// Restartable per policy.
ErrServerCrashed = errors.New("server crashed")

// ErrCrashLooping means the supervisor observed Policy.CrashLoop's
// threshold of ErrServerCrashed disconnects within its window and gave
// up restarting (state -> Failed) instead of relaunching again right
// away. Wraps the triggering ErrServerCrashed, so errors.Is(err,
// ErrServerCrashed) still matches. Produced only by Supervisor itself
// (watch's detector, reported once by the next Start) β€” never by
// Classify β€” so a single, isolated crash (handled by the ordinary
// restart policy, never escalated) can't be mistaken for a sustained
// loop.
ErrCrashLooping = errors.New("server crash-looping")

// ErrInitTimeout means the initialize handshake did not complete
// within the configured deadline.
ErrInitTimeout = errors.New("initialize timed out")
Expand Down
Loading
Loading