Skip to content

feat: lifecycle ordering preserved under concurrency (#4)#8

Merged
meymchen merged 2 commits into
mainfrom
feat/lifecycle-ordering-concurrency
Jun 14, 2026
Merged

feat: lifecycle ordering preserved under concurrency (#4)#8
meymchen merged 2 commits into
mainfrom
feat/lifecycle-ordering-concurrency

Conversation

@meymchen

Copy link
Copy Markdown
Owner

Closes #4.

Holds two LSP-spec lifecycle invariants under the concurrent dispatcher (issue #1), plus the structural changes they required.

Behavior

1. Initialize precedence (gate the spawn step)

  • Any request arriving while state == Uninitialized with method != "initialize" is answered ServerNotInitialized (-32002) without spawning a handler.
  • Any notification arriving while Uninitialized with method ∉ {initialized, exit} is dropped with a debug! log. This matches LSP §Initialize ("notifications should be dropped, except for the exit notification"); $/cancelRequest is dropped pre-init too.

2. Exit aborts in-flight work

  • All spawned handlers now live in a single tokio::task::JoinSet. On exit, the read-loop shutdown()s the set — aborting every in-flight handler (releasing their outgoing-sender clones) — then drops the master sender so the send-loop drains queued output and exits cleanly, and hands back the exit code.
  • This fixes the real bug: previously send_handle.await blocked until the slowest handler finished (handlers held out_tx clones), so exit effectively awaited a slow didOpen instead of aborting it.

Structural changes (steered to align with the LSP spec + ADR 0003)

  • process::exit moved to the binary boundary. dispatcher::run now returns Outcome::{TransportClosed, Exit(code)}; StdioBuilder::serve maps Exitprocess::exit(code), while lspf::serve returns. A library no longer kills your process, and exit is testable in-process.
  • initialize runs inline (ADR 0003's documented design) so the state → Running transition is synchronous before the next message is dispatched — the prior spawned initialize is what made gating racy. initialize is therefore no longer cancellable.
  • Registry shrinks to RequestId → CancellationToken (cancel routing only); handles live in the JoinSet.

Tests

  • New tests/lifecycle_ordering.rs: two table-driven init-precedence tests (requests → ServerNotInitialized; feature/unknown notifications dropped) + the exit-abort smoke test (slow didOpen in flight → serve returns < 500ms, no publishDiagnostics).
  • cancellation.rs reworked to cancel a post-init shutdown (spec-legal client timing) instead of an in-flight initialize, which is now dropped pre-init per the spec.
  • concurrency_cap.rs assertion corrected: inline initialize takes no concurrency permit → 2 acquire spans, not 3.
  • concurrency.rs and the stdio smoke tests pass unchanged.

Full suite green (20 tests), clippy clean, fmt applied.

Acceptance criteria

  • Pre-init request (method ≠ initialize) → ServerNotInitialized, no spawn
  • Pre-init notification (∉ {initialized, exit}) → dropped with debug!
  • On exit: all in-flight handles aborted; sender dropped; send-loop drains cleanly
  • Table-driven init-precedence test
  • Smoke test: slow didOpen in flight at exit is aborted, not awaited
  • Existing smoke tests still pass

Note for review

ADR 0003 also lists shutdown as inline, but it stays spawned here so it remains cancellable (the only request the current trait surface can cancel). The Outcome/exit-termination-boundary is a new decision with no ADR yet — happy to record both as a follow-up if wanted.

🤖 Generated with Claude Code

meymchen and others added 2 commits June 14, 2026 22:52
Hold two LSP-spec lifecycle invariants under the concurrent dispatcher:

1. Initialize precedence — gate the spawn step. Any request before
   `initialize` completes is refused with `ServerNotInitialized`
   (-32002) without spawning; every notification except `initialized`
   and `exit` is dropped with a `debug!` log (LSP §Initialize: drop all
   but `exit`; `$/cancelRequest` included).
2. Exit aborts in-flight work — all spawned handlers now live in one
   `JoinSet`; on `exit` the read-loop `shutdown()`s it, aborting every
   in-flight handler (which releases their outgoing-sender clones) before
   draining the send-loop. Previously `exit` blocked on the slowest
   handler instead of aborting it.

Supporting changes:
- `dispatcher::run` returns `Outcome::{TransportClosed, Exit(code)}`;
  `StdioBuilder::serve` maps `Exit` to `process::exit`, `lspf::serve`
  returns. This moves process termination to the binary boundary and
  makes `exit` testable in-process.
- `initialize` now runs inline (ADR 0003) so `state -> Running` is
  synchronous; the prior spawned `initialize` is what made gating racy.
  It is no longer cancellable.
- Registry shrinks to `RequestId -> CancellationToken` (cancel routing
  only); handles live in the `JoinSet`.

Tests:
- New `tests/lifecycle_ordering.rs`: table-driven request/notification
  init-precedence + exit-abort (slow didOpen aborted, prompt return, no
  publish).
- `cancellation.rs` reworked to cancel a post-init `shutdown` (spec-legal
  timing) rather than an in-flight `initialize`.
- `concurrency_cap.rs` span count corrected (inline `initialize` takes no
  permit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `handler.acquire_permit` span-visibility test (issue #3) flaked on CI:
under CPU contention a thread-local `set_default` subscriber is not
reliably observed when tokio polls spawned handler tasks, so spans went
uncaptured (sometimes none at all). Reproduced at ~30-45% under 2-core
parallel load; 0% serially.

Fix: move it to its own test binary (`tests/acquire_permit_span.rs`) with
a process-global subscriber (`.init()`), so capture is visible from any
poll thread and no sibling test pollutes it. Drive the two didOpen
handlers with explicit barriers instead of fixed sleeps so the queueing
window is deterministic, and wait for both to publish before inspecting
spans. 0 failures in 50 runs under heavy contention.

`concurrency_cap.rs` keeps only the cap=2 batching test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@meymchen meymchen merged commit 3c4a946 into main Jun 14, 2026
3 checks passed
@meymchen meymchen deleted the feat/lifecycle-ordering-concurrency branch June 14, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lifecycle ordering preserved under concurrency

1 participant