Skip to content

LSP #43: event-driven pull diagnostics (refresh + previousResultId + unchanged)#46

Merged
monkopedia-reviewer merged 1 commit into
mainfrom
lsp-event-driven-pull-diagnostics
Jun 4, 2026
Merged

LSP #43: event-driven pull diagnostics (refresh + previousResultId + unchanged)#46
monkopedia-reviewer merged 1 commit into
mainfrom
lsp-event-driven-pull-diagnostics

Conversation

@monkopedia-coder
Copy link
Copy Markdown
Collaborator

Part of #35; Fixes #43.

Replaces the Phase 2 blind-poll diagnostics path with the event-driven pull model the lsp-kotlin maintainer specified (two maintainer reviews captured in #43). Flag-gated throughout (lspEnabled default OFF + Config.isKotlinLspAvailable): flag-off ⇒ no behavioural change.

The event-driven model

  • Pull-mode detection (authoritative). After init, read the subprocess InitializeResult's ServerCapabilities.diagnosticProvider. Non-null ⇒ pull mode (there is no push capability flag). The pull machinery is created only when pull-mode; otherwise nothing runs. Sub-flags interFileDependencies / workspaceDiagnostics are surfaced (informational; a refresh already re-pulls every open doc, honouring inter-file breadth).
  • Backend-forwarder workspaceDiagnosticRefresh() override. ⚠️ Our backend DefaultLanguageClient subclass (the one talking to the subprocess) THROWS NotImplementedError for this unless overridden — the maintainer's flagged trap (the kodemirror frontend client's version is a harmless no-op, a different object). Overridden to re-pull every open doc on the engine's workspace/diagnostic/refresh ("results may have changed, re-pull") — exactly the cold-index-warmed case, and the steady-state driver that makes this event-driven instead of a poll loop.
  • Triggers: didOpen (immediate, with bounded cold-index backoff for the FIRST pull only), debounced didChange, and refresh. The unconditional retry/steady-poll loop is gone.
  • previousResultId + unchanged dedup. Per-doc resultId is threaded into each DocumentDiagnosticParams.previousResultId; an unchanged report (RelatedUnchangedDocumentDiagnosticReport, kind=="unchanged") does not republish — only a full report emits publishDiagnostics. Kills redundant upward pushes.
  • Phase 3 position translation is intact: diagnostic ranges still translate to csgs space before publishing.

Shaped for extraction

The pull/refresh/previousResultId/debounce/cold-index-backoff logic is factored into a cohesive, transport-agnostic PullDiagnosticsPublisher (kept internal to konstructor — no new public API). Given (pull seam, publish seam, readiness gate, debounce, backoff) it tracks per-doc resultId, pulls on trigger/refresh, emits on full/skips unchanged. BridgeLanguageServer supplies only the three seams (pull = subprocess textDocument/diagnostic; publish = translate-to-csgs + URI-rewrite + push; gate = initialized). Clean boundaries for the maintainer's later lift into lsp-ksrpc as a reusable DiagnosticBridge.

Verification

  • Flag-OFF / CI-equivalent — green: compiles; full clean :e2e:test green flag-off (incl. LspPipeTest, 0 failures/0 errors across suites); all-module ./gradlew spotlessCheck green; ./gradlew test green.
  • Unit tests (PullDiagnosticsPublisherTest, CI-runnable): pull-mode detection gating, unchanged-report skip, previousResultId threading, refresh→re-pull, close.
  • Real engine (LOCAL ONLY — not CI; CI has no binary): KONSTRUCTOR_KOTLIN_LSPintellij-server. The deliberate unresolved-reference error still arrives via the event-driven path (didOpen pull + refresh), translated to csgs line 1; and a settled doc does not double-publish (publishes after settle=1, after 20s idle=1 — the old 15s blind-poll would have grown the count). Engine path is not CI-tested.

🤖 Generated with Claude Code

…unchanged)

Replace the Phase 2 blind-poll diagnostics path with the event-driven pull
model the lsp-kotlin maintainer specified.

- Detect pull-mode authoritatively from the subprocess InitializeResult's
  ServerCapabilities.diagnosticProvider (non-null ⇒ pull mode; there is no
  push capability flag). Pull machinery runs ONLY when pull-mode.
- Override the BACKEND forwarder's workspaceDiagnosticRefresh() (the
  DefaultLanguageClient base THROWS NotImplementedError for it — the flagged
  trap; the frontend client's is a harmless no-op, a different object) to
  re-pull every open doc on workspace/diagnostic/refresh.
- Trigger pulls on didOpen + debounced didChange, and on refresh. Bounded
  cold-index backoff covers only the FIRST pull; steady state is event-driven.
- Thread DocumentDiagnosticParams.previousResultId per doc; skip republish on
  an unchanged report (RelatedUnchangedDocumentDiagnosticReport), only emit
  publishDiagnostics on a full report — kills redundant upward pushes.
- Factor the pull/refresh/previousResultId/debounce/backoff logic into a
  cohesive, transport-agnostic PullDiagnosticsPublisher (internal) shaped for
  later extraction into lsp-ksrpc as a reusable DiagnosticBridge.

Phase 3 position translation stays intact (ranges translate to csgs space
before publishing). Flag-gated throughout (flag-off ⇒ no change).

Tests: unit guards for pull-mode detection gating, unchanged-report skip,
previousResultId threading, refresh→re-pull, close. Real-engine integration
(local only, not CI): the deliberate error still flows via the event-driven
path (didOpen pull + refresh) and a settled doc does not double-publish
(publishes after settle=1, after 20s idle=1).

Part of #35; Fixes #43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@monkopedia-reviewer monkopedia-reviewer left a comment

Choose a reason for hiding this comment

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

Approved — full tier-1 review.

Event-driven logic (correct):

  • Pull-mode gating on ServerCapabilities.diagnosticProvider != null is authoritative (verified against the lsp lib: it's nullable and the only pull-mode signal); the publisher is created only in pull-mode.
  • The refresh override is on the backend forwarder (subprocess-facing DefaultLanguageClient subclass, whose base workspaceDiagnosticRefresh(): Nothing? throws) → onRefresh() re-pulls every open doc. Correct object, correct signature.
  • previousResultId threaded per-doc under a per-doc Mutex (race-free); full report's nullable resultId handled with ?.let, unchanged report's non-null resultId assigned directly — matches the lib's exact nullability.
  • when(report) over the sealed DocumentDiagnosticReport is exhaustive: full → publish, unchanged → advance id, no republish.
  • Cold-index backoff is bounded (maxAttempts, first-pull-only, loops only while uri in openDocs) — the old unconditional retry/steady-poll loop is fully removed. No leak: publisher runs on the bridge scope, torn down on exit(); onClose clears per-doc state.
  • Phase 3 csgs range translation preserved on the publish seam.

Cohesion: PullDiagnosticsPublisher is internal, transport-agnostic, clean seams (pull/publish/awaitReady/debounce/backoff). No new public API.

Tests: Unit test genuinely exercises pull-mode gating, unchanged-skip, previousResultId threading, refresh→re-pull, close — not tautological. Real-engine no-double-publish guard correctly CI-skipped (-Dintegration).

Flag-off: unchanged; e2e flag-off green, LSP inert.

CI: build-and-test green (verified remotely) — spotless incl. :backend:spotlessKotlinCheck, ./gradlew test (compiles+runs the new unit test), and :e2e:test -Pe2e under Xvfb all SUCCESSFUL.

Idiomatic, well-documented, scope contained to the diagnostics path.

@monkopedia-reviewer monkopedia-reviewer merged commit 76698e2 into main Jun 4, 2026
1 check passed
@monkopedia-reviewer monkopedia-reviewer deleted the lsp-event-driven-pull-diagnostics branch June 4, 2026 04:44
monkopedia-reviewer pushed a commit that referenced this pull request Jun 4, 2026
…e-time fixes (#47)

Part of #35; Fixes #40. Flag-gated throughout (lspEnabled default OFF +
Config.isKotlinLspAvailable): flag-off ⇒ no behavior change.

Teardown (reverse-channel / sub-service release):
- Frontend EditorPane tears down the LSP wiring on dispose (konstruction
  switch, flag toggle off, editor leaves composition) via produceState
  awaitDispose: shutdown→exit handshake then close() the lsp() server stub,
  run on LSPClient.scope so it survives the produceState coroutine cancel.
- BridgeLanguageServer.close() releases the stashed reverse frontendClient
  channel and does per-konstruction cleanup.
- Keep-warm preserved: per-konstruction teardown no longer forwards
  shutdown/exit/close to the SHARED subprocess stub (that killed the engine
  for other open konstructions and broke reopen); it sends a best-effort
  didClose so the engine forgets this doc and leaves the warm engine intact.

Leak test (CI-runnable, no engine binary): LspSubserviceLeakTest opens+closes
the nested lsp() sub-service 25x over an in-memory duplex ksrpc Connection and
asserts the reverse client channel is released every cycle (count returns to
baseline) — mirrors ksrpc's RpcSubserviceLeakTest.

3 PullDiagnosticsPublisher close-time fixes (maintainer #46 review):
1. uri-space key consistency — didClose keys onClose by params.textDocument.uri
   (same space as onOpen/onChange), not the stashed frontendUri.
2. clear-on-close — onClose emits publish(uri, emptyList()) (clearOnClose knob).
3. publish-after-close race — pullOnce re-checks uri in openDocs after the pull
   before publish. Unit tests added (9 publisher tests, was 6).

Real-engine reopen integration test (local-only): open→close→reopen still flows
diagnostics, clears on close, no stale diagnostics.

Module-extraction decision documented (KDoc): NOT extracted — bake in konstructor
first; extraction deferred per the lsp-kotlin maintainer.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

LSP: event-driven pull diagnostics (refresh + previousResultId + unchanged) — replace blind poll

2 participants