fix(#4060): wire LSP crash-loop pacing through backoff gate - #4108
Open
aheritier wants to merge 1 commit into
Open
fix(#4060): wire LSP crash-loop pacing through backoff gate#4108aheritier wants to merge 1 commit into
aheritier wants to merge 1 commit into
Conversation
Refs #4099 (follow-up to #4062/#4074): lifecycle.ErrServerCrashed was only ever produced inside the Supervisor's internal watch() goroutine and never surfaced through Start()'s return value, so a persistently-crashing LSP server relaunched at full speed forever — the watcher's own backoff resets to its base delay on every successful reconnect, even when the process dies again moments later. Crash-loop detection (pkg/tools/lifecycle/supervisor.go): - New Policy.CrashLoop{Threshold, Window} (default 3 crashes / 1 minute). watch() records a timestamp only for an actual crash — not a forced RestartAndWait close, not a clean (nil) exit — pruning entries outside Window on every record. A single crash, or crashes spread wider than Window apart, are still handled entirely by the ordinary Restart/Backoff policy and never reach this detector, preserving the existing "one-off crash restarts silently" behaviour. - Once Threshold is reached, watch() gives up (state -> Failed) instead of calling tryRestart again, and records a one-shot lifecycle.ErrCrashLooping (wraps the triggering ErrServerCrashed) on the supervisor. If a Stop lands in the narrow window between that decision and the record (raced concurrently), Stop's StateStopped wins instead of being clobbered back to Failed. - Start() consumes that one-shot report before attempting a connect: the call right after detection returns ErrCrashLooping without reconnecting, and the Start after that attempts a genuine reconnect. This one-shot handshake exists because only the caller (StartableToolSet's gate) knows when enough time has passed to retry for real — the supervisor itself has no notion of the gate's pacing. - PendingCrashLoopError() exposes the same report as a non-consuming peek, for callers that reach the supervisor outside the gate (see below). - Stop() discards any pending report and crash history as part of teardown. Classification (pkg/tools/startable_backoff.go) — Option A from #4099: startBackoffRetryable now arms directly on errors.Is(err, lifecycle.ErrCrashLooping), alongside the existing *modelerrors.StatusError branch. Chosen over synthesizing a StatusError because the supervisor has already done the "is this really a loop" judgment; forcing it through an HTTP-status-shaped signal would be a lossy, indirect encoding of a non-HTTP condition, and StatusError's Retry-After handling doesn't apply here anyway. A bare ErrServerCrashed (not escalated to ErrCrashLooping) deliberately still does NOT arm the gate — that fast, unpaced path belongs to the supervisor's own restart policy, matching the existing "deliberately excluded" list for ErrServerUnavailable/ErrTransport/etc. Wiring (pkg/tools/builtin/lsp/lsp.go): - The LSP ToolSet did not implement tools.StartReporter, so once StartableToolSet.startLocked latched started=true after the first successful Start, it never called the underlying toolset again — the supervisor's own watcher and ensureInitialized's eager per-request reconnects were the only recovery paths, both invisible to the gate. Adding IsStarted() = !State.IsTerminal() fixes this exactly for give-up cases (crash loop, or the pre-existing max-attempts exhaustion) while leaving ordinary transient Restarting alone: unlike the MCP toolset's IsStarted (Ready/Degraded only), LSP treats Restarting as still "started" so a one-off crash mid-auto-heal does not force StartableToolSet into Restart()'s 35s RestartAndWait path on every turn's pre-warm — only a full give-up (Failed/Stopped) asks the wrapper to get involved, which is exactly when the gate needs to pace. This also means a `strict`-profile LSP toolset (RestartNever) is now retried on the next turn after any failure rather than staying down until an explicit /toolset-restart — consistent with MCP's existing behaviour, documented in docs/tools/lsp/index.md. - ensureInitialized's lazy per-request Start() call bypasses StartableToolSet's gate entirely (it isn't the wrapper's paced TryStart), so it must not be the one to consume a one-shot crash-loop report on the gate's behalf. It now checks PendingCrashLoopError first and fails fast without reconnecting, leaving the report for the gate's own Start call to consume once its window elapses. - Known limitation, pre-existing and out of scope here: a raw crash (detected only in the background watcher) does not clear the handler's atomic `initialized` fast-path flag — only a fresh Connect or an explicit Close does — so a tool call arriving immediately after a crash (loop or not) can still observe a stale "initialized" session and attempt to use it before ensureInitialized's slow path (and the check above) ever runs. This affects the ordinary exhausted-restart give-up identically and predates this change; fixing it needs its own look at the crash-detection/session-teardown interaction. Tests: - pkg/tools/lifecycle/supervisor_test.go: TestSupervisor_CrashLoopStops- RestartingAndReportsOnStart (3rd crash trips the loop, no further Connect until the next Start, then a genuine reconnect), TestSupervisor_CrashLoopIgnoresCleanDisconnects, TestSupervisor_Crash- LoopIgnoresForcedRestart, TestSupervisor_CrashLoopStopIsClean, TestSupervisor_CrashLoopWindowPrunesOldCrashes (crashes spread wider than Window never accumulate), TestSupervisor_PendingCrashLoopError- IsNonConsuming, TestSupervisor_CrashLoopStopWinningRaceReportsStopped (regression test for the Stop/crash-loop race above). - pkg/tools/startable_backoff_test.go: TestStartBackoffRetryable_Err- ServerCrashed (bare crash does not arm), TestStartBackoffRetryable_Err- CrashLooping (arms and re-attempts after the window, via TryStart). - pkg/tools/builtin/lsp/lsp_crashloop_test.go (new): end-to-end TestLSPTool_CrashLoopArmsBackoffGate drives a real ToolSet wrapped in StartableToolSet (matching production wiring) against a fake LSP server subprocess (this test binary re-executed via TestMain, portable across the Linux/Windows CI matrix) that completes the handshake and exits 1 every time; proves the whole chain end to end via the exact spawn count. TestLSPTool_CrashLoopPendingReportBlocksDirectToolCalls proves the ensureInitialized fast-path check above. docs/tools/lsp/index.md: replaced the "not currently paced" caveat with a precise description of the new behaviour, including the strict-profile retry note above.
aheritier
commented
Sep 1, 2026
aheritier
left a comment
Collaborator
Author
There was a problem hiding this comment.
🤖 Automated implementer agent — this review was posted by the implementer bot from Docker Agentic Platform, not by a human developer
Self-review of record for #4099 (crash-loop pacing), following the same pattern used on #4062/#4074/#4104. This PR was independently reviewed twice by an automated reviewer agent before push (not GitHub-visible, since it runs in-sandbox); recording that review here since self-approval is blocked for the PR author.
Findings and resolutions (all addressed pre-push, single squashed commit 30ec91e6)
Stop()vs. crash-loop-detection race (pkg/tools/lifecycle/supervisor.go,watch()): a concurrentStop()winning the race against a crash-loop declaration could leaveStateasFailedinstead ofStopped. Fixed: the crash-loop branch re-checkss.stoppingafter re-acquirings.mu, bailing out without overwriting state ifStopalready won. Regression testTestSupervisor_CrashLoopStopWinningRaceReportsStopped— verified empirically to fail ~100% of the time with the fix reverted, and pass reliably with it restored.- Gate bypass via
ensureInitialized(pkg/tools/builtin/lsp/lsp.go): the LSP handler's lazy-start path calledsupervisor.Start()directly, which would consume the one-shotErrCrashLoopingreport outsideStartableToolSet's gate. Fixed: addedSupervisor.PendingCrashLoopError()(non-consuming peek);ensureInitializedchecks it first. Test:TestLSPTool_CrashLoopPendingReportBlocksDirectToolCalls. Known, documented, out-of-scope residual limitation (noted in the commit message): a raw crash doesn't clearlspHandler.initialized, so a tool call immediately following a crash can still occasionally hit the stale fast-path before the new check — pre-existing behavior, identically affects the ordinary exhausted-restart give-up path, not introduced by this change. - Missing crash-loop window-pruning test: added
TestSupervisor_CrashLoopWindowPrunesOldCrashes, exercising real time via a shortWindow+time.Sleep(matches this test file's existing convention;//nolint:forbidigowith reason, no injectable clock added). - Loose spawn-count assertion: tightened
GreaterOrEqualto exactassert.Equal(t, 3, spawnsAtLoop, ...)inTestLSPTool_CrashLoopArmsBackoffGate; confirmed exact and non-flaky across repeated runs. - Undocumented
strict-profile behavior change:docs/tools/lsp/index.mdupdated to note astrict-profile LSP toolset is now retried on the next turn after a full give-up (consistent with MCP's pre-existing behavior), instead of staying down until/toolset-restart.
Accepted as out-of-scope (not fixed, flagged for follow-up)
- A narrower pre-existing race between computing
crashLoopingand recordingcrashLoopErrthat could orphan a session with no watcher — reproducible identically on pre-existing non-crash-loopFailedpaths with noCrashLooppolicy involved at all; not introduced or worsened by this diff. - MCP toolsets silently inherit the same crash-loop protection (shared
Supervisor), with no MCP-specific test/doc coverage and no YAML knob to tuneThreshold/Window. Likely-intentional generalization consistent withSupervisor's shared-implementation design; candidate for a follow-up ticket rather than expanding this PR's scope. - An adjacent, unchanged
tracker.Fail(StateRestarting, waitErr)line sits next to the new code with the same un-guarded-against-Stop()race class as finding 1, but predates this diff (present since before #4060) and is not part of the new crash-loop path. - Base branch (
fix/startable-toolset-backoff-mcp-lsp) had moved forward ~24 commits after this branch was first cut; addressed by rebasing onto the new tip.
Verification
task build,task lint(golangci-lint +go run ./lint .+go mod tidy --diff) — clean, 0 issues.task test— full suite passes; two pre-existing, unrelated, environmental failures confirmed present identically on the base branch and unaffected by this diff:pkg/rag/treesitter(needsCGO_ENABLED=1/gcc, unavailable in this sandbox) andpkg/cache.TestFileCache_dedupSkipsRedundantWrite(filesystem mtime-resolution flake).- CI on this PR: 16 checks pass, 4 skip (image-push jobs gated on merge), 0 fail.
No blocking issues remain. Approving this changeset for merge (recorded here as a comment since GitHub blocks self-approval).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Automated implementer agent — this PR was opened by the implementer bot from Docker Agentic Platform, not by a human developer
Fixes #4099 (follow-up to #4060/#4062/#4074): wires LSP crash-loop failures through the
StartableToolSetbackoff gate.lifecycle.ErrServerCrashedwas only ever produced insideSupervisor.watch()'s background goroutine and never surfaced throughStart(), so a persistently-crashing LSP server relaunched at full speed forever.Design (Option A, per issue): direct sentinel, not a synthetic
StatusError. The Supervisor now detects a sustained crash loop itself (Policy.CrashLoop{Threshold, Window}, default 3 crashes / 1 minute — a single crash or crashes spread wider than the window are unaffected, handled entirely by the existing restart policy) and reports a one-shotlifecycle.ErrCrashLoopingthrough the nextStart()call instead of reconnecting.startBackoffRetryablearms directly onerrors.Is(err, lifecycle.ErrCrashLooping)— forcing this through an HTTP-status-shapedStatusErrorwould be a lossy encoding of a non-HTTP condition.The LSP
ToolSetdidn't implementtools.StartReporter, soStartableToolSetnever revisited it after the first successful start; addedIsStarted() = !State.IsTerminal()(deliberately looser than MCP's Ready/Degraded-only check, so an ordinary transient reconnect doesn't force a 35sRestartAndWaiton every turn — only a full give-up gets the wrapper involved, which is exactly when the gate needs to pace).Review findings addressed (full detail in the commit message):
Stop()-vs-crash-loop-detection race that could clobberStateStoppedback toStateFailed(regression test included).Supervisor.PendingCrashLoopError()(non-consuming peek) so LSP's per-request lazy-start path (ensureInitialized) can't bypass the gate by consuming the one-shot report itself.initializedfast-path flag (only a freshConnect/Closedoes), so a tool call immediately after a crash can still slip past the check above in practice. Pre-existing, affects the ordinary exhausted-restart give-up identically, and deserves its own look at the crash-detection/session-teardown interaction — noted in the commit message rather than fixed here.Changed files
pkg/tools/lifecycle/errors.goErrCrashLoopingsentinelpkg/tools/lifecycle/supervisor.goPolicy.CrashLoop, crash-time-window tracking, one-shotStart()report,PendingCrashLoopError()peek,Stop()/race hardeningpkg/tools/lifecycle/supervisor_test.gopkg/tools/startable_backoff.gostartBackoffRetryablearms onErrCrashLooping; doc comment rewrittenpkg/tools/startable_backoff_test.gopkg/tools/builtin/lsp/lsp.gotools.StartReporter(IsStarted),ensureInitializedconsultsPendingCrashLoopErrorpkg/tools/builtin/lsp/lsp_crashloop_test.godocs/tools/lsp/index.mdTesting
task build,task lint— clean.task test— passes; two unrelated pre-existing failures confirmed present on the base branch too and environmental to this sandbox:pkg/rag/treesitter(needsCGO_ENABLED=1/gcc, unavailable here) andpkg/cache.TestFileCache_dedupSkipsRedundantWrite(filesystem mtime-resolution flake).approve.Stacked on
fix/startable-toolset-backoff-mcp-lsp(#4074, which includes #4062) — targeting that branch, notmain.