Skip to content

fix(lsp-daemon): retry mid-request socket close/error instead of bailing out - #6256

Open
jaepo01 wants to merge 3 commits into
code-yeongyu:devfrom
jaepo01:fix/lsp-daemon-close-retry
Open

jaepo01 wants to merge 3 commits into
code-yeongyu:devfrom
jaepo01:fix/lsp-daemon-close-retry

Conversation

@jaepo01

@jaepo01 jaepo01 commented Jul 21, 2026 •

Copy link
Copy Markdown

Summary

When the shared LSP daemon closes or errors on the socket after a request has been written, the client reports requestWritten=true, which the retry loop in callToolViaDaemon treats as terminal:

if (error instanceof DaemonRequestError && (error.requestWritten || !isRetryableTool(name))) break;

In production this is almost always wrong. The daemon only closes a connected socket mid-request when one of the following happens:

  • the per-language-server idle timeout fires (lsp-core/src/lsp/manager.ts, 5 min default) and the backing LSP process — and therefore the in-flight request — is torn down,
  • the daemon itself is killed (parent proxy exit, OOM, crash, manual restart),
  • the host loses the unix socket.

In every one of those cases the request never produced a result, but the client still surfaces LSP daemon unreachable: daemon connection closed and never tries again — even though ensureDaemonRunning would respawn the daemon on the next attempt. End-user impact is a recurring MCP connection closed server=lsp WARN in opencode that resolves only after the next, unrelated tool call lazy-spawns a fresh daemon.

Changes

  • packages/lsp-daemon/src/daemon-client.ts: the close and error socket handlers now reject with requestWritten=false. This lets the existing 3-attempt retry loop re-ensure the daemon and resend. Timeouts still reject with the live requestWritten value, since the daemon may still be processing the original request when the timer fires.
  • Mutating tools remain single-shot: isRetryableTool(name) already gates rename/lsp_rename, so this change does not introduce duplicate-rename risk.
  • packages/lsp-daemon/test/daemon-client-retry.test.ts: the prior #given the server closes the connection after reading the request #then no retry happens test encoded the old terminal-on-close behavior, so it is split:
    • #given the server closes the connection after reading the request #when the next ensure reopens the daemon #then the second attempt succeeds — first server closes mid-request, the second ensure swaps in a stable server, and the second attempt returns "ok".
    • #given every attempt closes the connection after reading the request #when all three retries close #then the call is reported as a daemon connection failure — three close-in-a-row attempts still surface daemon connection closed and now assert requestCount === 3 (was 1).

QA & Evidence

  • RED before the source change: the new reopen-on-close test failed with expected "ok" — received: "LSP daemon unreachable: daemon connection closed", and the all-three-close test failed with expected requestCount 3, received 1.
  • GREEN after the source change:
    • packages/lsp-daemon/test/daemon-client-retry.test.ts: 11 pass / 0 fail (was 10 pass / 0 fail before this PR).
    • packages/lsp-daemon full suite: 114 pass. The 8 pre-existing failures are unrelated vitest-v4 / bun-runner environment issues (vi.mocked, vi.advanceTimersByTimeAsync, ./package.json resolution under bun), all of which fail identically on upstream/dev without this change.
  • npx tsc --noEmit (project packages/lsp-daemon): clean.
  • biome check src/daemon-client.ts test/daemon-client-retry.test.ts: clean.

Risks & Residuals

  • A mid-request close with a side-effecting-but-not-rename tool (e.g. a future code-action apply) could now run twice on retry. Today the only mutating tool routed through the daemon is rename, which isRetryableTool() already excludes, so this risk is theoretical for the current toolset and is the same shape of risk the existing retry-after-auth-refresh path already accepts.
  • This is a client-side resilience fix; it does not change daemon-side idle-shutdown behavior or address the underlying MCP connection closed WARN surfacing in opencode (a separate transport-reconnect concern tracked locally; an upstream opencode issue will be filed for that side).

Related


Summary by cubic

Retries LSP daemon calls when the socket closes or errors mid-request, but only for idempotent read-only tools; previously any close/error after the request was written surfaced immediately as "daemon connection closed" even though the daemon would respawn on the next attempt.

  • close/error handlers in daemon-client.ts now report requestWritten=false only for idempotent tools; timeouts still report the live value.
  • Adds retryable-tools.ts with an explicit allowlist of idempotent tools (status, diagnostics, goto_definition, find_references, symbols, prepare_rename), replacing the old rename-only deny list.
  • Non-idempotent tools (rename, install_decision, format) keep at-most-once semantics and are never retried after a close.

Written for commit ff671c8. Summary will update on new commits.

Review in cubic

@jaepo01

jaepo01 commented Jul 21, 2026

Copy link
Copy Markdown
Author

Follow-up: filed anomalyco/opencode#38088 for the opencode-side gap (the MCP transport layer itself does not lazily reconnect after a transient ). This PR independently recovers most occurrences from the plugin side; the opencode issue is the deeper transport-layer fix.

@jaepo01

jaepo01 commented Jul 21, 2026

Copy link
Copy Markdown
Author

Context: relationship to upstream opencode issue

While investigating the root cause behind this fix, I filed anomalyco/opencode#38088 and it was flagged as a duplicate of the canonical tracker #25287 (still OPEN). I've closed #38088 as a duplicate.

Why this PR is still independently useful

#25287 proposes a transport-aware retry in opencode core (packages/opencode/src/mcp/index.ts → convertMcpTool), but it has not landed yet (OPEN since 2026-05-01, MR mentioned on 2026-05-04 but still unresolved).

This PR addresses the same class of failure from the plugin side: when the LSP daemon socket closes/errors mid-request, daemon-client.ts currently bails out and never resets requestWritten, so subsequent retries on the same plugin instance stay broken for the rest of the session. Resetting requestWritten = false in the close/error handlers lets the next call retry cleanly.

Layering

Layer Issue Fix location Status
opencode core anomalyco/opencode#25287 convertMcpTool transport retry OPEN (not yet landed)
plugin (this PR) — daemon-client.ts requestWritten reset under review

Once the core fix in #25287 ships, this plugin-side retry becomes defense-in-depth rather than the primary recovery path — but both layers are independently useful today, and the plugin-side fix remains valid even after the core fix lands (cheap idempotent reset, no behavior change on the happy path).

@code-yeongyu

Copy link
Copy Markdown
Owner

[sisyphus-bot] Retry semantics change: mid-request socket close now retries instead of failing. This is a behavioral tradeoff — idempotent operations benefit from resilience, but non-idempotent operations risk double-execution. Recommend owner review to confirm this is safe for all tool call types. CI test/typecheck runs needed.

@MoerAI MoerAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reproducing read-only recovery through a real daemon and for keeping the patch small.

Do not globally classify post-write socket close or error as unwritten. That reverses the existing at-most-once boundary, retries persisted install_decision, and would also replay the current disk-writing format tool:
https://github.com/code-yeongyu/oh-my-openagent/blob/b5cbae3fb8778a06f70aa7aada35c8be72f0dba0/packages/lsp-core/src/tools/definitions.ts

Restrict post-write retries to an explicit allowlist of idempotent tools, or preserve at-most-once behavior when commit status is indeterminate. Add a close-after-dispatch regression proving every non-idempotent tool executes once while read-only tools can recover.

The daemon's idle shutdown already returns while a connection is active, so do not rely on idle teardown as proof that a closed in-flight request was uncommitted.

Please update onto current dev, retain the read-only restart coverage, and rerun the current required CI contexts:
https://github.com/code-yeongyu/oh-my-openagent/rules/17777319

…ing out

When the daemon closes or errors on the socket after a request has been
written, the client previously reported requestWritten=true, which the
retry loop treats as a terminal failure (the request may have reached
the daemon and could produce a duplicate side effect). In practice, the
daemon only closes mid-request when its backing language server has
been killed (idle shutdown, OOM, crash, parent-proxy exit), so the
request never produced a result and the user is left without one.

Pass requestWritten=false from both the close and error handlers so the
caller retries against a freshly ensured daemon. Mutating tools are
still gated by isRetryableTool(), so rename stays single-shot. Timeout
keeps requestWritten=true because the daemon may still be processing
the original request when the timer fires.

Tests:
- The previous 'no retry happens' close test is split: the retry-now-
  succeeds case asserts the second ensure reopens the daemon and the
  second attempt returns 'ok', and the all-three-attempts-close case
  asserts the terminal failure still surfaces 'daemon connection
  closed' with requestCount=3.
- daemon-client-retry.test.ts: 11 pass / 0 fail.
- biome check + tsc --noEmit clean.
… tools

Address PR review: globally classifying post-write socket close/error as
unwritten reversed the at-most-once boundary, retried persisted
install_decision writes, and replayed the disk-writing format tool.

- Introduce an explicit idempotent-tool allowlist (retryable-tools.ts):
  status, diagnostics, goto_definition, find_references, symbols,
  prepare_rename. The close/error handlers report requestWritten=true for
  non-idempotent tools (terminal failure, no resend) while read-only
  tools keep the retry-on-fresh-daemon recovery.
- Rename the retry gate semantics: isRetryableTool now consults the
  allowlist instead of a rename-only denylist.
- Add close-after-dispatch regressions: format and install_decision
  execute exactly once (requestCount=1, single ensure), while
  diagnostics recovers on the second attempt after a daemon reopen.

Tests: daemon-client-retry.test.ts 14 passed (11 existing + 3 new).
lsp-daemon suite: 165 passed (22 files). tsc --noEmit clean. biome
check clean.
@jaepo01
jaepo01 force-pushed the fix/lsp-daemon-close-retry branch from 24a3bd0 to 62ab5fc Compare September 1, 2026 07:02
@jaepo01

jaepo01 commented Sep 1, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review — all points addressed in 62ab5fc (rebased onto current dev):

Idempotent allowlist replaces the global classification. New retryable-tools.ts defines the explicit allowlist from your list: status, diagnostics, goto_definition, find_references, symbols, prepare_rename. The close/error handlers now report requestWritten = requestWritten && !isIdempotentTool(name) — so non-idempotent tools (including format and persisted install_decision) keep at-most-once semantics whenever commit status is indeterminate, and only read-only tools recover via retry on a fresh daemon.

Retry gate renamed accordingly. isRetryableTool no longer uses the rename-only denylist; it consults the allowlist.

Close-after-dispatch regressions added (daemon-client-retry.test.ts):

  • format → request executed exactly once (requestCount=1, single ensure), terminal 'daemon connection closed'
  • install_decision → same at-most-once guarantee
  • diagnostics → recovers on the second attempt after the ensure reopens the daemon (exactly-once recovery)

Idle-teardown point noted — no longer relied upon anywhere; the allowlist itself is what bounds retryability.

Suite: lsp-daemon 165 passed (22 files), daemon-client-retry 14 passed (11 existing + 3 new). tsc --noEmit + biome clean. Note the CI workflow run shows action_required (first-contributor workflow approval) whenever it's convenient to enable it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lsp-daemon Changes under packages/lsp-daemon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants