feat: lifecycle ordering preserved under concurrency (#4)#8
Merged
Conversation
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>
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.
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)
state == Uninitializedwithmethod != "initialize"is answeredServerNotInitialized(-32002) without spawning a handler.Uninitializedwithmethod ∉ {initialized, exit}is dropped with adebug!log. This matches LSP §Initialize ("notifications should be dropped, except for theexitnotification");$/cancelRequestis dropped pre-init too.2. Exit aborts in-flight work
tokio::task::JoinSet. Onexit, the read-loopshutdown()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.send_handle.awaitblocked until the slowest handler finished (handlers heldout_txclones), soexiteffectively awaited a slowdidOpeninstead of aborting it.Structural changes (steered to align with the LSP spec + ADR 0003)
process::exitmoved to the binary boundary.dispatcher::runnow returnsOutcome::{TransportClosed, Exit(code)};StdioBuilder::servemapsExit→process::exit(code), whilelspf::servereturns. A library no longer kills your process, andexitis testable in-process.initializeruns inline (ADR 0003's documented design) so thestate → Runningtransition is synchronous before the next message is dispatched — the prior spawnedinitializeis what made gating racy.initializeis therefore no longer cancellable.RequestId → CancellationToken(cancel routing only); handles live in theJoinSet.Tests
tests/lifecycle_ordering.rs: two table-driven init-precedence tests (requests →ServerNotInitialized; feature/unknown notifications dropped) + the exit-abort smoke test (slowdidOpenin flight →servereturns < 500ms, nopublishDiagnostics).cancellation.rsreworked to cancel a post-initshutdown(spec-legal client timing) instead of an in-flightinitialize, which is now dropped pre-init per the spec.concurrency_cap.rsassertion corrected: inlineinitializetakes no concurrency permit → 2 acquire spans, not 3.concurrency.rsand the stdio smoke tests pass unchanged.Full suite green (20 tests),
clippyclean,fmtapplied.Acceptance criteria
initialize) →ServerNotInitialized, no spawn{initialized, exit}) → dropped withdebug!exit: all in-flight handles aborted; sender dropped; send-loop drains cleanlydidOpenin flight atexitis aborted, not awaitedNote for review
ADR 0003 also lists
shutdownas inline, but it stays spawned here so it remains cancellable (the only request the current trait surface can cancel). TheOutcome/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