Skip to content

fix(lsp-core): fail fast when LSP start or initialize hangs or dies asynchronously - #7244

Open
AceRothstein71 wants to merge 1 commit into
code-yeongyu:devfrom
AceRothstein71:issue/6486-lsp-init-fast-timeout
Open

fix(lsp-core): fail fast when LSP start or initialize hangs or dies asynchronously#7244
AceRothstein71 wants to merge 1 commit into
code-yeongyu:devfrom
AceRothstein71:issue/6486-lsp-init-fast-timeout

Conversation

@AceRothstein71

@AceRothstein71 AceRothstein71 commented Aug 25, 2026

Copy link
Copy Markdown

What

Bounds the LSP cold start (spawn + initialize round-trip) with a short, env-overridable deadline so a server that dies asynchronously after spawn or hangs on initialize fails in seconds instead of blocking for the full 60s init ceiling.

  • constants.ts: new START_TIMEOUT_MS = 10_000 plus resolveStartTimeoutMs() reading OMO_LSP_START_TIMEOUT_MS (positive integers only; missing/invalid values fall back to the default).
  • errors.ts: new LspStartTimeoutError whose message names OMO_LSP_START_TIMEOUT_MS for legitimately slow starters.
  • transport.ts: new startTimeoutMs timeout option and createStartGuard(), an AbortSignal that aborts with LspProcessExitedError when proc.exited resolves (async spawn death, stderr tail included) or with LspStartTimeoutError when the deadline elapses. The deadline timer is unref'd and disposed once initialize settles.
  • connection.ts: initialize() passes the guard signal into the initialize request and disposes the guard in finally.

Manager, reaper, spawn resolution, and the existing INIT_TIMEOUT_MS backstop are untouched.

Why

LspClientTransport.start() only checked the synchronous exitCode right after spawn. An async spawn failure has not fired yet at that point (exitCode still null), so start() resolved healthy and control reached initialize(). Pending JSON-RPC requests are never rejected on process/stream close, so the initialize request sat until INIT_TIMEOUT_MS = 60_000. In production this stalled background workers until a 5-minute stale-cancel silently discarded completed work; disabling uninstalled servers dropped worker time from ~5min to ~14s, which is exactly the bound this PR makes automatic.

Verified

Failing-first regression suite packages/lsp-core/src/lsp/transport-start-fast-fail.test.ts (given/when/then):

  • RED before the fix: a spawned server that consumes the initialize write then exits without responding, and an alive-but-silent server, both blocked past the 10s test ceiling (pending on the 60s init timeout). Evidence: .omo/evidence/20260824-6486-lsp-init-timeout/red-failing-first.txt.
  • GREEN after: all 5 tests pass in ~1.2s - late-exit rejects LspProcessExitedError, silent server rejects LspStartTimeoutError at a 200ms injected deadline, a responsive echo server (fixtures/initialize-echo-server.mjs) still initializes and stops cleanly under an armed guard, and env parsing is unit-pinned.
  • Env wiring proven end-to-end: OMO_LSP_START_TIMEOUT_MS=200 alone bounds a silent server without any explicit option.
  • bun test packages/lsp-core: 105 pass / 0 fail (includes real cold-start integration suites).
  • bun run typecheck (tsgo root + script + all package projects): green.
  • Full evidence recorded under .omo/evidence/20260824-6486-lsp-init-timeout/.

Risk

A legitimately slow-starting server that needs more than 10s to answer initialize now fails fast instead of waiting 60s; operators can raise OMO_LSP_START_TIMEOUT_MS (the error message says so). The 60s INIT_TIMEOUT_MS remains as the outer backstop for consumers that override the start deadline upward.

Fixes #6486


Summary by cubic

Fail fast when an LSP process dies after spawn or hangs on initialize. Previously we waited the full 60s init ceiling; now we bound start+initialize with a 10s default deadline, overridable via OMO_LSP_START_TIMEOUT_MS.

  • New start deadline: START_TIMEOUT_MS = 10_000 with resolveStartTimeoutMs() reading OMO_LSP_START_TIMEOUT_MS (positive integers only; defaults otherwise).

  • New error: LspStartTimeoutError names the env var in the message.

  • Transport: add startTimeoutMs option and createStartGuard() that aborts initialize via an AbortSignal on process exit or on the deadline; timer is unref’d and disposed once initialize settles.

  • Connection: initialize() passes the guard signal and disposes it in finally.

  • Required action for slow servers (>10s): raise OMO_LSP_START_TIMEOUT_MS or pass startTimeoutMs to the lsp-core client to avoid premature failure.

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

Review in cubic

…synchronously

An LSP server that fails asynchronously after spawn (exitCode still null at
the synchronous check) or hangs on initialize blocked callers for the full
60s INIT_TIMEOUT_MS because pending JSON-RPC requests are never rejected on
process exit. Bound the cold start with a short, env-overridable deadline
(OMO_LSP_START_TIMEOUT_MS, default 10s): an AbortSignal guard around the
initialize round-trip aborts with LspProcessExitedError when the process
exits and with LspStartTimeoutError when the deadline elapses.

Verified: failing-first regression suite (late-exit + silent-server red at
the old behavior, green in ~1.2s after), healthy-server non-regression,
env parsing unit + wiring proof, bun test packages/lsp-core 105 pass,
repo-wide tsgo typecheck green. Evidence: .omo/evidence/20260824-6486-lsp-init-timeout/

Fixes code-yeongyu#6486
@github-actions github-actions Bot added the lsp-core Changes under packages/lsp-core label Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lsp-core Changes under packages/lsp-core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(lsp-core): LSP that hangs on initialize or fails asynchronously after spawn blocks for 60s with no fast timeout

1 participant