Skip to content

fix(lsp): dispatch notifications off read loop - #759

Open
PierrunoYT wants to merge 15 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-758-lsp-notification-deadlock
Open

fix(lsp): dispatch notifications off read loop#759
PierrunoYT wants to merge 15 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-758-lsp-notification-deadlock

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • dispatch server notifications through a bounded per-client worker queue
  • preserve notification ordering without blocking JSON-RPC response delivery
  • allow notification handlers to make re-entrant Client.Call requests safely
  • add regression coverage for re-entrant calls and ordered notification delivery

Verification

  • go test ./internal/lspn- go vet ./...n- go test ./...n- go run ./cmd/zero-release buildn- go run ./cmd/zero-release smoken- git diff HEAD --checkn

Environment limitations

  • go test -race ./internal/lsp requires CGO, which is disabled in this environment
  • pinned golangci-lint and govulncheck build with Go 1.25 and cannot analyze this Go 1.26.5 repository
  • make fmt-check could not run because make is unavailable; touched files were formatted with gofmt`n
    Fixes fix(lsp): re-entrant notification handlers can deadlock #758

Summary by CodeRabbit

  • Bug Fixes
    • Prevented deadlocks when notification handlers trigger client calls.
    • Ensured ordered, bounded LSP notification delivery with reliable overload handling.
    • Improved shutdown behavior by draining accepted notifications and rejecting later ones.
    • Improved diagnostics synchronization, staleness detection, and cancellation handling.
    • Rejected oversized incoming LSP frames to limit excessive memory use.
  • Tests
    • Expanded coverage for notification ordering, bursts, overloads, shutdown, frame limits, and diagnostics lifecycle edge cases.

Copilot AI review requested due to automatic review settings July 19, 2026 13:48

Copilot AI 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.

Pull request overview

This PR addresses a deadlock risk in the LSP client by moving server notification handling off the single protocol read-loop goroutine, enabling notification handlers to safely perform re-entrant Client.Call requests without blocking response delivery.

Changes:

  • Added a per-client notification worker loop and bounded queue to serialize notification dispatch off the read loop.
  • Updated the client read loop to enqueue notifications instead of invoking the handler inline.
  • Added regression tests for re-entrant calls from notification handlers and notification ordering.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
internal/lsp/client.go Introduces a notification queue + worker goroutine so notification handlers don’t run on the protocol read loop.
internal/lsp/client_test.go Adds tests for re-entrant calls inside notification handlers and for preserving notification ordering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/lsp/client.go Outdated
Comment thread internal/lsp/client_test.go
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 73c2fa2a-27a5-4921-b4c9-fa2438050278

📥 Commits

Reviewing files that changed from the base of the PR and between 7f09894 and 9fc9ff1.

📒 Files selected for processing (1)
  • internal/lsp/client_test.go

Walkthrough

Client notification handling now uses a bounded FIFO queue and a dedicated dispatch goroutine. Handlers can call Client.Call without blocking response processing. Accepted notifications drain during shutdown, and diagnostic synchronization uses receipt sequences to reject stale publishes.

Changes

LSP notification dispatch

Layer / File(s) Summary
Buffered notification dispatch
internal/lsp/client.go
Incoming notifications receive receipt sequences and run through a bounded FIFO worker.
Notification shutdown lifecycle
internal/lsp/client.go
Frame-size limits, queue overload handling, shutdown admission closure, and accepted-notification draining are implemented.
Notification dispatch validation
internal/lsp/client_test.go
Tests cover re-entrant calls, ordering, burst response progress, lossless draining, EOF handling, receipt progress, overload behavior, and post-close rejection.
Diagnostic receipt sequencing
internal/lsp/documents.go, internal/lsp/manager.go
Diagnostic baselines use client receipt sequences. Catch-up handling runs during cancellation, closure, and debounce completion.
Diagnostic synchronization validation
internal/lsp/manager_test.go
Tests cover stale versions, queued publishes, closure, scheduling races, EOF draining, cancellation catch-up, and deadline handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LSPServer
  participant readLoop
  participant notificationLoop
  participant DiagnosticSession
  participant Manager
  LSPServer->>readLoop: Send publishDiagnostics
  readLoop->>notificationLoop: Enqueue notification with receipt sequence
  Manager->>DiagnosticSession: Capture global notification baseline
  notificationLoop->>DiagnosticSession: Handle diagnostics with sequence
  DiagnosticSession-->>Manager: Accept only sequence newer than baseline
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The frame-size limit is unrelated to the re-entrant notification deadlock addressed by [#758], although queue lifecycle and diagnostic catch-up support asynchronous dispatch. Move frame-size enforcement to a separate issue or PR, or link an issue that requires the new maxFrameBytes behavior.
Docstring Coverage ⚠️ Warning Docstring coverage is 26.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the primary change: dispatching LSP notifications outside the read loop.
Linked Issues check ✅ Passed The PR adds serialized dispatch, preserves order, supports re-entrant Client.Call, defines shutdown behavior, and adds regression tests required by [#758].
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/lsp/client_test.go`:
- Around line 230-282: Run the concurrent test
TestClientNotificationHandlerCanCallClient with Go’s race detector, using go
test -race ./... locally or in a supported CI environment, and verify that
notificationLoop and Client.Call are race-free.

In `@internal/lsp/client.go`:
- Around line 43-44: Replace the fixed-size notification channel controlled by
notificationQueueSize with an unbounded queue pattern, using an intermediate
goroutine and dynamically growing slice to decouple readLoop from slow
notification handlers. Preserve notification execution order while ensuring
protocol reads never block, including when handlers call Client.Call and more
than 64 notifications arrive before the response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 35a90c72-04a7-499a-9369-97226e4696f5

📥 Commits

Reviewing files that changed from the base of the PR and between ce4a996 and 9d8eb98.

📒 Files selected for processing (2)
  • internal/lsp/client.go
  • internal/lsp/client_test.go

Comment thread internal/lsp/client_test.go
Comment thread internal/lsp/client.go Outdated

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Preserve notifications when the dispatch queue overflows
    internal/lsp/client.go:214
    When a notification handler is blocked (including in the re-entrant Client.Call case this PR adds), a server can send more than 64 notifications before replying. enqueueNotification discards the oldest message, so a textDocument/publishDiagnostics for a different URI can be lost permanently. The corresponding session.waitForDiagnostics then times out and Manager.Check returns no diagnostics even though the server published them. This is the resolved CodeRabbit request in a different form: the follow-up commit trades the read-loop deadlock for silent protocol-event loss, and TestClientNotificationOverflowDropsOldestInOrder explicitly codifies that loss. Use a lossless non-blocking queue (or another explicit, observable policy that preserves required diagnostics) and add an overflow regression test that asserts delivery of all relevant notifications.

Addresses the P1 on Gitlawb#759.

The previous commit traded the read-loop deadlock for silent protocol-event
loss: at 64 queued items enqueueNotification discarded the OLDEST message. A
textDocument/publishDiagnostics is the server's only report for its URI, so a
dropped one makes session.waitForDiagnostics time out and Manager.Check return
no diagnostics even though the server published them — and the overflow test
codified that loss as intended behavior.

The queue is now unbounded: the read loop still never blocks (so a handler may
make a re-entrant Client.Call), and nothing is discarded. Both alternatives are
worse — blocking on a full buffer is the original deadlock, and dropping loses
state the protocol never repeats. Growth is bounded in practice by what the
server emits while a handler runs; the backing array is released as soon as the
queue drains, so a burst does not retain capacity afterwards.

Tests: TestClientNotificationQueueIsLossless replaces the drop-oldest test, and
the end-to-end burst test now also asserts that all 512 notifications sent while
a handler was blocked in a re-entrant call arrive, in order. Both were checked
against the old drop-oldest policy and fail there (64 of 512 delivered, FIFO
broken), so they have teeth.
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed 4b3d17b (on a merge of current main).

[P1] Preserve notifications when the dispatch queue overflows — you read it exactly right: the follow-up commit traded the read-loop deadlock for silent protocol-event loss, and TestClientNotificationOverflowDropsOldestInOrder codified that loss as intended behaviour. The queue is now lossless and unbounded: the read loop still never blocks (so a handler can make a re-entrant Client.Call), and nothing is discarded.

The two alternatives are both worse, and the code now says so where the decision lives:

  • Blocking on a full buffer is the original deadlock — the handler waits for a response frame the blocked reader can no longer deliver.
  • Dropping loses state the protocol never repeats. A textDocument/publishDiagnostics is the server's only report for that URI, so a dropped one makes session.waitForDiagnostics time out and Manager.Check return nothing even though the server published findings.

On the growth question that motivated the bound: it is bounded in practice by how much the server emits while one handler runs, and dequeueNotification now releases the backing array once the queue drains, so a burst does not leave its capacity (or its consumed items) alive afterwards. A handler that never returns would grow it without limit — that is a handler bug queue pressure makes visible, not something the transport should hide by discarding messages.

Tests, and their teeth

  • TestClientNotificationQueueIsLossless replaces the drop-oldest test: 512 enqueued (8× the old bound), all 512 dequeued in FIFO order, and the drained queue asserted back to nil capacity.
  • The end-to-end TestClientNotificationBurstDoesNotBlockResponse now asserts delivery, not just non-blocking: with a handler parked in a re-entrant Call, the server sends 512 notifications plus the response; the test requires the response to arrive and all 512 notifications to reach the handler in order. That is the "asserts delivery of all relevant notifications" case you asked for, and it also covers @copilot's burst/pressure gap.

I verified both fail under the old policy rather than assuming it — reintroducing the 64-item drop-oldest cap gives:

--- FAIL: TestClientNotificationBurstDoesNotBlockResponse
    received 64 of 512 burst notifications; the queue lost messages
--- FAIL: TestClientNotificationQueueIsLossless
    notification = "notification-448", want "notification-000" (queue must stay FIFO)

@coderabbitai on the race detector — I cannot run it on this host and want to be straight about that rather than tick the box: go test -race requires cgo, and there is no C toolchain here (-race requires cgocgo: C compiler "gcc" not found). The concurrent tests pass without it, and the repo's race-enabled CI job is what will actually exercise notificationLoop against Client.Call. If it reports anything I will follow up immediately. The synchronisation itself is narrow: notifyMu guards only the slice, the handler runs on the worker goroutine, and the wake-up channel has capacity 1 with a drain-until-empty loop, so a signal dropped by the default branch is always covered by the pending token.

Validation (Windows host, Go 1.26.5): go build ./..., go vet ./..., go test ./... -count=1 all pass; GOOS=linux|darwin go vet ./internal/lsp clean; gofmt clean on both changed files.

@jatmn — ready for another look. I can't use the reviewer-request button on this repo, hence the mention.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR diff summary ==='
git diff --stat HEAD^ HEAD -- internal/lsp/client.go internal/lsp/client_test.go || true
git diff --unified=80 HEAD^ HEAD -- internal/lsp/client.go internal/lsp/client_test.go || true

echo '=== client implementation outline ==='
ast-grep outline internal/lsp/client.go --items all --view expanded || true

echo '=== relevant notification and shutdown references ==='
rg -n -C 5 --type go \
  '(notificationLoop|enqueueNotification|dequeueNotification|notifyMu|notifyWake|notify|close\(|closed|readLoop|NotificationHandler)' \
  internal/lsp/client.go internal/lsp/client_test.go

Length of output: 42682


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== client construction and close call sites ==='
rg -n -C 6 --type go \
  '(NewClient\s*\(|\.Close\s*\(\)|IsClosed\s*\(\)|failPending\s*\()' \
  internal

echo '=== session/process transport lifecycle candidates ==='
rg -n -C 6 --type go \
  '(exec\.Command|StdoutPipe|StdinPipe|io\.Pipe|Wait\s*\(|Kill\s*\(|Process\.|client\s+\*Client|Client\s+\*Client)' \
  internal

Length of output: 165231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Server lifecycle ==='
sed -n '1,115p' internal/lsp/server.go

echo '=== Test LSP server lifecycle ==='
sed -n '1,115p' internal/lsp/manager_test.go

echo '=== all direct Client Close invocations within LSP ==='
rg -n -C 4 --type go '\bclient\.Close\(\)|\.client\.Close\(\)' internal/lsp

Length of output: 13491


@PierrunoYT The lossless live-client queue fixes the earlier event-loss issue, and the FIFO/wakeup logic is sound. I found one shutdown defect that should be addressed before merge:

  • [P1] Do not retain notifications after Client.Closeinternal/lsp/server.go Line 66 calls Client.Close() before stdin is closed and the language-server process reaches EOF. Client.notificationLoop exits as soon as closed is signaled, but readLoop can still receive frames and enqueueNotification at internal/lsp/client.go Line 228 appends each notification to the now-unconsumed, unbounded queue.

    A server that emits notifications while handling shutdown/exit—or any direct Client user whose reader remains open after Close—therefore causes retained growth until the transport finally closes. Reject notifications once closed and clear any queued entries as part of close, coordinating this with notifyMu so an enqueue racing with shutdown cannot leave an item retained. Please add a regression test that keeps the input writable after Client.Close and verifies subsequent notification frames neither invoke the handler nor accumulate in notifyQueue.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/lsp/client.go (1)

190-210: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Shutdown drops queued notifications and doesn't wait for an in-flight handler.

Once c.closed fires the loop returns immediately, so anything still in notifyQueue is discarded, and Close() returns while a handler may still be executing (including one blocked in Client.Call, which will then fail with the read error). The linked issue asks for defined callback lifecycle/shutdown behavior — either drain-then-exit, or document that post-Close notifications are intentionally abandoned and that handlers may outlive Close.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/lsp/client.go` around lines 190 - 210, Define and implement explicit
shutdown behavior for Client.notificationLoop: either drain all queued
notifications and wait for any in-flight handler before Close returns, or
intentionally abandon them and document that handlers may outlive Close. Update
the c.closed handling, notification dispatch, and Close synchronization
consistently so Client.Call/read-error behavior matches the chosen lifecycle
contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/lsp/client.go`:
- Around line 190-210: Define and implement explicit shutdown behavior for
Client.notificationLoop: either drain all queued notifications and wait for any
in-flight handler before Close returns, or intentionally abandon them and
document that handlers may outlive Close. Update the c.closed handling,
notification dispatch, and Close synchronization consistently so
Client.Call/read-error behavior matches the chosen lifecycle contract.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ae5688e-795a-4216-bf98-b140ebd1ee67

📥 Commits

Reviewing files that changed from the base of the PR and between 9d8eb98 and 4b3d17b.

📒 Files selected for processing (2)
  • internal/lsp/client.go
  • internal/lsp/client_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 25, 2026
@PierrunoYT
PierrunoYT requested a review from jatmn July 25, 2026 13:38
Addresses the shutdown defect CodeRabbit found in the lossless queue.

Client.Close signals the worker loop to exit, but readLoop keeps consuming
frames until the transport ends — and Server.Shutdown closes the client BEFORE
closing stdin, so a language server emitting notifications while it handles
shutdown/exit fed an unbounded queue that nothing would ever drain.

failPending's closeOnce now marks the queue closed and releases whatever is in
it, and enqueueNotification drops instead of appending once that flag is set.
Both run under notifyMu, so an enqueue racing with shutdown either sees the flag
and drops its item or appends just before it and has the item cleared. Queued
work is dropped rather than drained: the worker is gone by definition of close,
and callers that need diagnostics collect them before shutting the client down.

TestClientDropsNotificationsAfterClose keeps the transport writable after Close,
as Server.Shutdown does, and requires 64 subsequent notifications to neither
reach the handler nor accumulate. Verified against the unguarded enqueue, where
it reports 64 retained notifications.
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Fixed in d8ef0f3 — good catch, and it was a real defect I introduced by removing the bound: with an unbounded queue, a closed client whose reader is still open retains everything the server sends.

[P1] Do not retain notifications after Client.Close

  • failPending's closeOnce now calls closeNotifications, which sets notifyClosed and drops the queue (notifyQueue = nil).
  • enqueueNotification returns early when notifyClosed is set instead of appending.

Both run under notifyMu, which is what makes the race you flagged safe: an enqueue concurrent with shutdown either observes the flag and drops its item, or appends just before the flag is set and has that item cleared by closeNotifications a moment later. Either way nothing survives close.

Queued-but-unhandled notifications are dropped rather than drained, and the code says why: the worker loop is gone by definition of close, and the caller that needs diagnostics (Manager.Check) collects them before shutting the client down. The comment on enqueueNotification names the concrete trigger you identified — Server.Shutdown calls Client.Close() at server.go:66 and only then s.stdin.Close(), so the read loop keeps feeding frames in between.

Regression testTestClientDropsNotificationsAfterClose does exactly what you asked: it keeps the transport writable after Close (mirroring that window in Server.Shutdown), sends 64 notification frames, and requires that none reach the handler and that notifyQueue stays empty while notifyClosed is set. It also checks a pre-Close notification still works, so the test cannot pass by breaking dispatch outright.

Verified it has teeth by disabling the guard:

--- FAIL: TestClientDropsNotificationsAfterClose
    closed client retained 64 queued notifications

go build ./..., go vet ./..., go test ./... -count=1 pass; GOOS=linux|darwin go vet ./internal/lsp clean; gofmt clean. The -race caveat from my previous comment still stands — no C toolchain on this host, so the race-enabled CI job is what exercises notificationLoop against Client.Call and this new close path.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== focused commit diff ==='
git diff --unified=90 d8ef0f3^ d8ef0f3 -- internal/lsp/client.go internal/lsp/client_test.go

echo '=== client symbols ==='
ast-grep outline internal/lsp/client.go --items all --view expanded

echo '=== notification lifecycle references ==='
rg -n -C 8 --type go \
  '(closeNotifications|enqueueNotification|dequeueNotification|notificationLoop|notifyClosed|notifyQueue|notifyMu|failPending|closeOnce|func \(c \*Client\) Close)' \
  internal/lsp/client.go internal/lsp/client_test.go

Length of output: 36975


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== notification handler call sites ==='
rg -n -C 10 --type go '\.SetNotificationHandler\s*\(' internal

echo '=== server shutdown and client ownership ==='
ast-grep outline internal/lsp/server.go internal/lsp/manager.go --items all --view expanded 2>/dev/null || true
rg -n -C 12 --type go \
  '(func \(.*\) Shutdown|func \(.*\) Close|\.Close\(\)|SetNotificationHandler|publishDiagnostics|waitForDiagnostics)' \
  internal/lsp/server.go internal/lsp/manager.go internal/lsp

Length of output: 66810


@PierrunoYT Confirmed. d8ef0f3 resolves the post-close retention path:

  • notifyMu correctly makes the close/enqueue race lossless with respect to cleanup: notifications are either rejected after closure or cleared during closure.
  • The new regression test exercises the writable-transport window after Client.Close, verifies pre-close dispatch still works, and confirms post-close notifications neither invoke the handler nor remain queued.

🐇 ✅


✏️ Learnings added
Learnt from: PierrunoYT
URL: https://github.com/Gitlawb/zero/pull/759

Timestamp: 2026-07-25T13:46:00.669Z
Learning: In `internal/lsp/client.go`, `Client.Close` may precede transport closure during `Server.Shutdown`, so the read loop can still receive server notifications. `Client.closeNotifications` must set `Client.notifyClosed` and clear `Client.notifyQueue` under `Client.notifyMu`, while `Client.enqueueNotification` checks that flag under the same mutex, preventing retained notifications after closure.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 25, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Preserve the diagnostics receipt boundary when dispatching asynchronously
    internal/lsp/client.go:181
    Manager.Check captures publishBaseline before it sends the next document version, but publishCount is incremented only when the queued callback eventually reaches session.handleNotification. A publish for the previous version can therefore already be in notifyQueue when a later Check takes its baseline; when that old callback is eventually run, it looks newer than the baseline and satisfies waitForDiagnostics. Since version-less publishDiagnostics messages are intentionally accepted, the stale result is then returned for the new text (and the 300ms debounce can finish before the real response arrives). The synchronous reader never had this receipt-vs-dispatch gap. Carry a receive sequence/timestamp through the queue and baseline against receipt, or otherwise ensure pre-sync queued diagnostics cannot satisfy the new check; add a regression for queued version-less diagnostics.

  • [P2] Put a failure policy on the lossless notification backlog
    internal/lsp/client.go:229
    The lossless, non-blocking design correctly avoids the old deadlock, but it replaces pipe backpressure with an unbounded slice of full json.RawMessage payloads. A language server can keep emitting notifications faster than the single handler drains them (or while a re-entrant handler is waiting for a response), and readLoop will parse and retain every frame until Zero exhausts its heap. This does not require a permanently stuck handler; any sustained producer faster than the consumer is enough. The claimed bounded queue and the 512-message test do not cover this case. Define an observable overload policy—such as failing/evicting the LSP client at a high-water mark—rather than retaining unbounded protocol input.

…n backlog

Addresses jatmn's two Gitlawb#759 review findings:

- Manager.Check captured its diagnostics baseline from publishCount,
  which only advances once session.handleNotification actually runs
  for a URI. Dispatch happens off a queue now, so a publish for a
  version Check is about to supersede could already be sitting
  received-but-undispatched at baseline-capture time, then run
  afterward and look newer than the baseline purely because handling
  lagged receipt. Since many servers omit the version field, the
  existing staleness check in handleNotification (positive-version
  only) couldn't catch it either. Stamp every notification with a
  receipt sequence at enqueue time (Client.NotificationSeq), carry it
  through the queue to the handler, and baseline against receipt
  instead of against how many publishes have been processed.

- The lossless notification queue had no failure policy: a language
  server sustaining a higher notification rate than the single
  handler can drain (no stuck handler required) would retain every
  full json.RawMessage payload without bound. Add notifyQueueLimit;
  exceeding it fails and closes the client observably, letting the
  manager's existing dead-session eviction take over on next use,
  instead of growing the queue until the heap gives out.

Both regression tests are verified to fail against the pre-fix code:
the receipt-vs-baseline race was reproduced directly against the
pre-fix API (confirmed real, not hypothetical) before porting the
test to the fixed API, and the overload test was confirmed to fail
with the limit check removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed 6659ee0, addressing both findings from the latest round (one P1, one P2).

[P1] Preserve the diagnostics receipt boundary when dispatching asynchronously (internal/lsp/client.go:181)

Manager.Check captured its baseline from publishCount, which only advances once session.handleNotification actually runs for a URI — but dispatch happens off a queue now, so a publish for a version Check is about to supersede could already be sitting received-but-undispatched at the moment baseline is captured, then run afterward and look "newer than baseline" purely because handling lagged receipt. Since many servers omit the version field, handleNotification's own staleness check (positive-version only) couldn't catch this either — the stale result would reach the caller for the new text, and the 300ms debounce could finish before the real response arrived.

Fixed by stamping every notification with a receipt sequence at enqueue time (Client.NotificationSeq), carrying it through the queue to the handler, and baselining waitForDiagnostics against receipt order instead of processed-count. A notification already sitting in the queue when a baseline is captured now has seq <= baseline no matter when it's actually dispatched.

Added TestPublishBaselineRejectsAlreadyQueuedPublish, which reproduces the exact interleaving deterministically (enqueue the stale publish → capture baseline → dispatch it) via the same bare-Client technique the existing lossless-queue test uses (no goroutine timing involved). Before writing it, I reproduced the bug directly against the pre-fix code to confirm it's real (not hypothetical) — it is: the stale, receipt-before-baseline publish incorrectly satisfied waitForDiagnostics every time.

[P2] Put a failure policy on the lossless notification backlog (internal/lsp/client.go:229)

The lossless design correctly avoided the old deadlock but replaced pipe backpressure with an unbounded slice of full json.RawMessage payloads — any sustained producer faster than the single consuming handler (not just a permanently-stuck one) would grow it until the heap gives out. The 512-message burst test never exercised this.

Added notifyQueueLimit (4096): exceeding it fails and closes the client observably (IsClosed() becomes true), letting Manager.sessionFor's existing dead-session eviction take over on next use — the same recovery path a crashed server already goes through — instead of retaining unbounded protocol input.

Added TestClientFailsOnNotificationBacklogOverload, confirming the client closes exactly at the limit and the queue is released, not left to grow past it.

Both regression tests verified to have teeth: the receipt/baseline race was proven against the actual pre-fix code before being ported to the fixed API, and the overload test was confirmed to fail with the limit check temporarily removed.

Validation:

  • go build/vet/test ./... on Windows (this host): all pass.
  • go test ./internal/lsp/... -count=30 on Windows: pass.
  • go test -race ./internal/lsp/... -count=10 via WSL: pass, plus go build/go vet ./... under WSL — clean.

@jatmn ready for another look.

@PierrunoYT
PierrunoYT requested a review from jatmn July 26, 2026 13:01
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 26, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Bound retained notification bytes, not just notification count
    internal/lsp/client.go:263
    notifyQueueLimit permits 4,096 complete peer-controlled json.RawMessage payloads to accumulate while a handler is blocked. A language server sending 1 MiB diagnostics or custom notifications can therefore retain several GiB before this count-based limit closes the client, which defeats the new overload policy and can OOM Zero. Apply a byte budget or reject oversized frames before retaining the backlog.

  • [P2] Wake a diagnostic wait when overload closes the client
    internal/lsp/documents.go:169
    The 4,097th queued notification calls failPending, but waitForDiagnostics only waits on its publish channel or the caller context. The overflow clears the queue without waking that waiter, so a Manager.Check(context.Background(), ...) that is already waiting blocks forever (and the normal diagnostics path waits its full timeout) even though the client has been deliberately failed. Observe client.closed or otherwise release the waiters when closing the client.

  • [P2] Restore the delayed PID-file regression coverage dropped by the stale merge
    internal/config/command_test.go:151
    Relative to the current base, this PR deletes TestAssertProcessTerminatedWaitsForDelayedPIDFile and its polling helper, which were added by main's #802 to cover atomic PID-file publication after the first ENOENT. The remaining helper reads immediately, so the Windows startup-race coverage is lost even though this LSP change has no config-test scope. Rebase onto the current base or restore that test/helper.

ampagent and others added 2 commits July 26, 2026 20:24
Track retained notification payload bytes in addition to queue length and fail the client before the backlog exceeds 16 MiB. Wake diagnostic waiters when that failure closes the client, preserving already-received fresh diagnostics during debounce. Add race-enabled regression coverage for byte overload cleanup and client-failure wakeup.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-b80c-717b-b041-5d5135a61d96

Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/lsp/manager_test.go`:
- Around line 457-475: Use separate timeout deadlines for the two sequential
polling loops in the test: create one deadline for waiting on NotificationSeq()
to reach 2, then reset the deadline immediately before waiting for
client.IsClosed(). Keep both existing timeout failures and polling behavior
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e0cfd15f-668b-49f2-9bdc-3aaaded5ea10

📥 Commits

Reviewing files that changed from the base of the PR and between 2dedb2d and ce95206.

📒 Files selected for processing (4)
  • internal/lsp/client.go
  • internal/lsp/client_test.go
  • internal/lsp/documents.go
  • internal/lsp/manager_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/lsp/client_test.go
  • internal/lsp/documents.go

Comment thread internal/lsp/manager_test.go
@PierrunoYT
PierrunoYT requested a review from jatmn July 28, 2026 14:05

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Stamp receipt sequence when the frame is read, not when it is enqueued
    internal/lsp/client.go:208
    Async dispatch introduced a window synchronous readLoop did not have: readMessage consumes the frame, then json.Unmarshal and enqueueNotification run later on the same goroutine while Manager.Check can call publishBaseline() on another. A delayed textDocument/publishDiagnostics for superseded text can therefore be assigned seq > baseline even though it was already on the wire before the new sync. Version-less publishes then pass waitForDiagnostics and reach the caller with stale findings — the same receipt-boundary class TestPublishBaselineRejectsAlreadyQueuedPublish covers for queue ordering, but via read/enqueue reordering. Stamp sequence (or otherwise pin the receipt boundary) at read time, or hold notifyMu across read-to-enqueue for notifications.

  • [P2] Finish the notification-drain logic consistently
    internal/lsp/documents.go:171
    ce95206 added notificationsDone() draining for client.closed in the seq <= baseline wait, but the same concern is only partly applied elsewhere. (1) The ctx.Done() arm at line 171 never waits on drain and only re-checks handled publishSeq; if cancellation wins while a publishDiagnostics frame is already accepted but still queued, Manager.Check can return (nil, nil) despite wire receipt, and the inner drain select at line 186 can still let ctx.Done() abort before the worker finishes. (2) The debounce-phase client.closed arm at line 214 returns true immediately without drain or re-check, so Check can read an earlier handled publish from diagnosticsFor while a newer accepted publish is still queued. Please apply the same drain-then-recheck pattern on both paths, with regressions for bounded-context cancellation and for debounce-plus-closure with a follow-up publish still in the queue.

  • [P3] Use separate polling deadlines in EOF/drain tests
    internal/lsp/manager_test.go:457
    TestWaitForDiagnosticsDrainsAcceptedPublishAfterTransportEOF and TestClientDrainsAcceptedNotificationsAfterTransportEOF (internal/lsp/client_test.go:482) reuse one one-second deadline for waiting on NotificationSeq() and then for client.IsClosed(). Under slow CI scheduling the first loop can consume most of the budget and make the second loop fail intermittently. CodeRabbit's inline request on this is still valid — reset the deadline before each wait loop.

  • [P3] Cap Content-Length before allocating the frame body
    internal/lsp/client.go:457
    notifyQueueByteLimit is enforced only in enqueueNotification, but readMessage already allocates the full body first. A single oversized notification can therefore bypass the new byte policy before failPending runs. This is a gap in the overload feature added here, not a regression in the read path itself. Reject or cap Content-Length before make([]byte, contentLength) if you want the byte budget to be authoritative.

Follow-up (not blocking this PR)

These are real hardening opportunities, but they predate or sit outside the core deadlock/receipt fixes and I would not block merge on them alone.

  • Baseline before sync (internal/lsp/manager.go:76): publishBaseline() has run before sync() on main as well. A version-less publish received in that gap can still satisfy a later wait. Moving the baseline snapshot to after sync (or tying freshness to committed document version) would narrow that window; it is not a regression introduced by async dispatch.
  • Read-then-close before enqueue (internal/lsp/client.go:208): a frame decoded just before notifyClosed is set can be dropped at enqueue. The window is very narrow and mostly overlaps intentional post-Close rejection during Server.Shutdown; worth hardening only if you want wire-consumed frames to be lossless up to the close boundary.

Stamping a notification's receipt seq at enqueue left a window async dispatch
had opened: readMessage consumes the frame, then json.Unmarshal and the enqueue
run later on the same goroutine while another can capture a publishBaseline. A
publishDiagnostics for superseded text could land above a baseline taken after
it was already off the wire and — most servers omit the version field the
staleness check needs — reach the caller as the answer for the new text. The
read loop now stamps every frame the moment it leaves the wire and hands that
seq to the enqueue, so decoding cost sits outside the boundary. ReceiptSeq
replaces NotificationSeq: the clock counts frames, and gaps in it are fine.

Notification drain handling is now uniform. Every exit from waitForDiagnostics
catches the worker up on notifications it already accepted before reading what
was recorded, instead of only the seq<=baseline closure arm doing so. That
covers cancellation racing an accepted-but-queued publish (which used to make
Check return (nil, nil) for text the server had answered), and the debounce
closure arm returning while a newer publish was still queued (which handed the
caller the older one). After closure the drain is bounded — admission is shut
and a handler's re-entrant Call fails immediately — so it is no longer cut short
by ctx; on a live client the catch-up is bounded by a short grace instead.

Also cap Content-Length before allocating the body, so the notification byte
budget is authoritative rather than measured after a huge frame is materialized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed all four findings in b0722ff.

[P1] Receipt is stamped at read time. readLoop now calls stampReceipt() on every frame the moment readMessage returns — before json.Unmarshal, before the enqueue — and passes that seq into enqueueNotification, which no longer stamps. The expensive, peer-sized part of the pipeline is now outside the boundary. Frames that turn out not to be notifications consume a number too, so I renamed NotificationSeqReceiptSeq: it is a receipt clock over frames, and gaps in it are meaningless to the only thing that reads it (publishSeq[uri] > baseline).

Regression: TestPublishBaselineRejectsFrameReadBeforeBaseline drives the seam directly — stamp (frame leaves the wire), baseline (concurrent Check), then enqueue + dispatch (read loop finishes decoding). With stamping at enqueue it fails; with stamping at read the stale publish lands at seq == baseline and is rejected.

[P2] The drain logic is uniform now. Every exit from waitForDiagnostics goes through catchUpNotifications() before the caller reads diagnosticsFor — the two seq <= baseline arms became identical and now share freshAfterCatchUp. Catch-up is "has the worker handled every notification the reader had already accepted", tracked with acceptedSeq/handledSeq on the client plus a broadcast channel that pulses as the worker advances.

The two policies differ deliberately:

  • Closed client: wait for notificationsDone() unconditionally, no longer racing ctx.Done(). After closure admission is shut and a handler's re-entrant Call fails immediately, so the drain is bounded and it is the last chance those frames get.
  • Live client: bounded by notificationCatchUpGrace (50ms). I did not make this one unbounded: on a live client the worker can legitimately sit in a handler's re-entrant Call awaiting a server response, and an unbounded wait there would hang Check well past a deadline the caller has already blown. The grace only applies when the worker is actually behind — with the session's own parse-and-record handler it is virtually always caught up, so the common path costs one comparison. It is a session field so tests can pin it.

Regressions: TestWaitForDiagnosticsCatchesUpOnContextCancellation (bounded-context cancellation with a publish queued behind a busy worker — previously false, i.e. Check returning (nil, nil) for text the server had answered) and TestWaitForDiagnosticsDrainsFollowUpPublishOnCloseDuringDebounce (debounce + closure with a newer publish still queued — previously returned the older "first"; it also cancels the ctx mid-drain to pin that the drain no longer aborts on it). Both fail against the prior behavior.

[P3] Content-Length is capped before allocation. maxFrameBytes = notifyQueueByteLimit, rejected in readMessage before make([]byte, contentLength), which is what makes the byte budget authoritative — a notification can no longer be materialized in full and only then measured against a limit it already blew past. Covered by TestReadMessageRejectsOversizedFrame. Worth flagging the tradeoff: this also caps responses at 16 MiB, so a hypothetical larger response now fails the client instead of being read. A legitimate LSP frame is orders of magnitude below that, but say the word if you'd rather have a separate, looser transport ceiling.

[P3] Separate polling deadlines. Both EOF/drain tests (manager_test.go, client_test.go) now reset the deadline before the IsClosed() loop so slow scheduling in the first loop can't consume the second one's budget.

I left the two follow-ups alone as non-blocking, per your note.

Validation

  • go build ./..., go vet ./...
  • go test ./internal/lsp -count=3
  • go test -race ./internal/lsp -count=2 (WSL/Linux; -race needs cgo, unavailable on this host)
  • go test ./...
  • Each new test verified to fail against the pre-fix behavior
  • gofmt -l internal/lsp clean, git diff --check clean

@PierrunoYT
PierrunoYT requested a review from jatmn July 28, 2026 20:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/lsp/documents.go (1)

32-32: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale API name in the comment: Client.NotificationSeq no longer exists.

The clock this map is stamped from is Client.ReceiptSeq (client.go:391). Pointing readers at a removed symbol defeats the purpose of the note, especially since the whole point of this PR is which clock wins.

📝 Proposed fix
-	publishSeq  map[string]int64           // uri -> receipt seq of latest publish (see Client.NotificationSeq)
+	publishSeq  map[string]int64           // uri -> receipt seq of latest publish (see Client.ReceiptSeq)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/lsp/documents.go` at line 32, Update the comment for the publishSeq
map to reference Client.ReceiptSeq instead of the removed Client.NotificationSeq
symbol, preserving the existing explanation that the map stores the latest
publish receipt sequence.
🧹 Nitpick comments (2)
internal/lsp/documents.go (1)

260-267: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The closed-client drain is unbounded — consider a generous cap.

<-drained has no ceiling and no ctx escape. The reasoning holds for the session's own handler (parse + record) and for re-entrant Calls failing fast post-closure, but SetNotificationHandler is public: any handler that blocks on something other than a client call wedges Manager.Check forever. A generous absolute cap (seconds, not the 50ms grace) would preserve the "last chance for these frames" intent while keeping teardown provably finite — matching client.go's own stance that "a user handler may be blocked".

♻️ Sketch
 	if s.client.IsClosed() {
-		<-drained
-		return
+		// Bounded generously: the backlog cannot grow, but a wedged third-party
+		// handler must not pin this caller forever.
+		timer := time.NewTimer(closedDrainCap)
+		defer timer.Stop()
+		select {
+		case <-drained:
+		case <-timer.C:
+		}
+		return
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/lsp/documents.go` around lines 260 - 267, Bound the closed-client
drain in the surrounding Manager.Check flow instead of waiting on drained
indefinitely. Add a generous absolute timeout of several seconds, distinct from
the existing 50ms grace period, and return once either drained completes or the
cap expires; preserve the current immediate-drain behavior when it finishes
within the cap.
internal/lsp/manager_test.go (1)

645-753: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Solid test — one coupling to keep in mind.

This test pins the post-closure drain as unbounded by setting catchUpGrace: 0. If the bounded-cap suggestion on documents.go Lines 260-267 is adopted, that cap must be a separate constant (not catchUpGrace), or this test starts asserting the opposite of what it intends.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/lsp/manager_test.go` around lines 645 - 753, Keep the post-closure
notification drain in waitForDiagnostics independent from session.catchUpGrace,
which this test sets to zero to require an unbounded drain even after context
cancellation. If adding a cap in the documents.go closure path, introduce and
use a separate constant so catchUpGrace continues to govern only the live-client
grace period.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/lsp/documents.go`:
- Line 32: Update the comment for the publishSeq map to reference
Client.ReceiptSeq instead of the removed Client.NotificationSeq symbol,
preserving the existing explanation that the map stores the latest publish
receipt sequence.

---

Nitpick comments:
In `@internal/lsp/documents.go`:
- Around line 260-267: Bound the closed-client drain in the surrounding
Manager.Check flow instead of waiting on drained indefinitely. Add a generous
absolute timeout of several seconds, distinct from the existing 50ms grace
period, and return once either drained completes or the cap expires; preserve
the current immediate-drain behavior when it finishes within the cap.

In `@internal/lsp/manager_test.go`:
- Around line 645-753: Keep the post-closure notification drain in
waitForDiagnostics independent from session.catchUpGrace, which this test sets
to zero to require an unbounded drain even after context cancellation. If adding
a cap in the documents.go closure path, introduce and use a separate constant so
catchUpGrace continues to govern only the live-client grace period.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d443d42f-7352-4a6e-8e9a-beddfe9d36be

📥 Commits

Reviewing files that changed from the base of the PR and between ce95206 and b0722ff.

📒 Files selected for processing (4)
  • internal/lsp/client.go
  • internal/lsp/client_test.go
  • internal/lsp/documents.go
  • internal/lsp/manager_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/lsp/client_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 28, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] catchUpNotifications can return before all accepted notifications are handled
    internal/lsp/documents.go:268
    b0722ff made every waitForDiagnostics exit call catchUpNotifications() so Manager.Check reads diagnostics only after the worker has applied everything already accepted. On a live client the helper snapshots acceptedNotificationSeq() once, then returns as soon as handledThrough() >= target. While it is waiting, readLoop can still accept another publishDiagnostics (or any notification) with a higher receipt seq; when the worker finishes the stale target, handled >= target becomes true and the function returns even though the newer item is still queued. Debounce/timer/ctx.Done() success arms then return true and diagnosticsFor can answer with an older publish while a newer accepted publish for the same URI is still pending — the same “newest received, not the older one that woke the wait” failure the closed-client drain regression was written for, but on the live path. This is PR-owned: main had no catch-up helper because the handler ran inline; async dispatch created the gap and this snapshot leaves it open even when grace has not expired. Please loop until handledSeq catches the current acceptedNotificationSeq() (or equivalent), not a one-shot snapshot, and add a live-client regression where a follow-up publish is accepted during catch-up behind a busy worker.

Follow-up (not blocking this PR)

  • Stamp→enqueue seam in catch-up (internal/lsp/documents.go:268, internal/lsp/client.go:228): catchUpNotifications waits on acceptedNotificationSeq() while baselines use ReceiptSeq() stamped before decode/enqueue. A publishDiagnostics frame can sit in the narrow unmarshal window while catch-up thinks the worker is caught up. The window is much smaller than the enqueue/baseline race b0722ff fixed and overlaps the read-then-close hardening you already listed as non-blocking; worth tightening only if you want wire-to-handler losslessness end to end.

  • Production grace vs cancellation test (internal/lsp/manager_test.go:572, internal/lsp/documents.go:168): TestWaitForDiagnosticsCatchesUpOnContextCancellation uses a five-second catchUpGrace; production sessions use 50ms. The test therefore does not exercise the live-client timeout path. That timeout is intentional — unbounded catch-up on a live client would hang Check past an expired caller deadline — so (nil, nil) after grace under sustained worker backlog is a documented tradeoff, not an oversight. A production-grace regression would still be useful if you want the contract spelled out in tests.

Catch-up snapshotted the accepted sequence once, but the reader keeps running
while it waits. The worker finishing the item that was last at the start
satisfied the check, so catch-up returned with a newer publish for the same URI
still queued — and the caller read diagnosticsFor and got the older one. That is
the failure the closed-client drain exists to prevent, reproduced on the live
path by the async dispatch this PR introduces.

The target is now re-read every pass. This cannot spin forever: the live-client
grace already caps the wait no matter how fast the server publishes, and a
closed client still drains unconditionally because its backlog cannot grow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed in dd0a7fa, plus the optional grace test.

[P2] Catch-up follows the target instead of snapshotting it. You're right, and it is the same bug I wrote the closed-client drain regression for — I just left the live path holding a stale number. The target is now re-read every pass. It cannot spin: the live-client grace already caps the wait however fast the server publishes, and a closed client still drains unconditionally because its backlog cannot grow.

The regression took two attempts, and the first one was wrong in a way worth describing. My initial version asserted diagnosticsFor == "second" after catch-up returned — and it passed with the bug restored, because the worker records the follow-up publish microseconds later and won the race against the test's read. A test that passes either way is worse than no test.

The version that landed pins the state instead of racing it: the handler for "second" blocks, so at the moment the stale target is satisfied the worker is provably inside that handler with the newer publish unrecorded. The assertion is then that catch-up has not returned there. Against the snapshot implementation it fails with catch-up returned on a stale target; diagnostics = [... "first"], which is precisely the failure you described.

Optional: production-grace regression. Taken. TestCatchUpNotificationsGivesUpAfterTheLiveGrace holds the worker permanently behind and asserts catch-up returns after — and not before — its grace. It uses 25ms rather than the 5s the other tests pin, so the timeout path is actually exercised. That spells out the tradeoff in tests: an unbounded live-client wait would hang Check past a deadline the caller has already blown, so a permanently-behind worker costs the grace and no more.

Not taken: the stamp→enqueue seam in catch-up. Agreed it is real, but it is a strictly smaller window than the one b0722ff closed and it overlaps the read-then-close hardening already listed as non-blocking — I would rather do wire-to-handler losslessness as one deliberate change than shave one edge of it here.

Validation: go build ./..., go vet ./..., go test ./..., go test ./internal/lsp -count=3, and go test -race ./internal/lsp -count=2 under WSL/Linux (-race needs cgo, unavailable on this host). git diff --check and gofmt -l clean.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 29, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Required macOS smoke check is failing on head dd0a7fa
    .github/workflows/ci.yml (Smoke macos-latest)
    GitHub Actions run 30453340514 reports Smoke (macos-latest) failure on commit dd0a7fa; Ubuntu, Windows, Performance Smoke, Security & code health, and Zero Review on the same head all passed. The job runs go test ./..., and this PR’s only production changes are in internal/lsp, so the failure is PR-owned even though the exact failing test could not be confirmed here — job logs require repository authentication and were not available in this review environment. Please reproduce on macos-latest, identify the failing test from the job log, fix the underlying defect or stabilize the test, and re-run the full matrix before merge.

Follow-up (not blocking this PR)

  • dd0a7fa catch-up re-read looks responsive to the prior P2 snapshot finding. catchUpNotifications now re-reads acceptedNotificationSeq() each pass (internal/lsp/documents.go:271-281), which addresses the enqueued-but-still-behind case from b0722ff. I do not have a remaining actionable defect against that specific request on current head.

  • Stamp→enqueue seam in catch-up (internal/lsp/documents.go:279, internal/lsp/client.go:228-240): ReceiptSeq() advances at read while acceptedNotificationSeq() advances at enqueue, so catch-up can theoretically return while a frame is still in the read loop’s unmarshal window. That is a much narrower window than the queue/backlog race dd0a7fa fixed, and it was already listed as non-blocking follow-up on the prior round. Worth hardening only if you want wire-to-handler losslessness end to end.

  • Live-client grace tradeoff (internal/lsp/documents.go:168, :287-288): production catchUpGrace is 50ms and catch-up deliberately gives up afterward; debounce success arms then return true without a post-catch-up publishSeq re-check like freshAfterCatchUp uses on the pre-baseline cancel/close paths. That asymmetry is intentional — unbounded live catch-up would hang Check past caller deadlines — and is spelled out in TestCatchUpNotificationsGivesUpAfterTheLiveGrace. Not a merge blocker unless you want to change that contract.

  • Baseline before sync (internal/lsp/manager.go:76): publishBaseline() still runs before sync(); a version-less publish arriving in that gap can satisfy a later wait. That window predates async dispatch.

  • Closed-client drain with wedged third-party handler (internal/lsp/documents.go:265): post-close catch-up waits unbounded on notificationDrained. Production uses the session’s fast handler; exported SetNotificationHandler could still wedge Manager.Check if a handler blocks off Client.Call.

  • Stale API name in comment (internal/lsp/documents.go:32): publishSeq still references Client.NotificationSeq; the clock is Client.ReceiptSeq().

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed the macOS smoke failure in 7f09894.

The failing regression waited for ReceiptSeq(), which advances as soon as a frame leaves the wire, before JSON decoding and notification enqueue. On slower macOS scheduling, the test could release the first handler before the follow-up notification was actually accepted, allowing catch-up to correctly return against the current accepted target. The test now waits for acceptedNotificationSeq() so it deterministically establishes the queue state it intends to verify.

Also updated the stale Client.NotificationSeq comment reference to Client.ReceiptSeq.

Validation:

  • TestCatchUpNotificationsFollowsPublishesAcceptedWhileWaiting, 100 consecutive runs
  • go test ./internal/lsp -count=3
  • go test ./...
  • go vet ./...
  • release build and smoke test
  • static lint: 0 issues
  • govulncheck: no vulnerabilities
  • touched-file formatting and git diff --check

Ready for CI and maintainer re-review.

@PierrunoYT
PierrunoYT requested a review from jatmn July 29, 2026 20:13
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 29, 2026
jatmn
jatmn previously approved these changes Jul 29, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Vasanthdev2004
Vasanthdev2004 previously approved these changes Aug 1, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. I re-verified on current head (7f09894) rather than standing on the earlier rounds, and the concurrency story holds up under everything I threw at it.

I checked each guarantee by mutating the production code and confirming the suite goes red:

  • Async dispatch is load-bearing. Putting the handler back inline in readLoop (internal/lsp/client.go:240) reproduces #758 exactly — the run hangs and the goroutine dump shows readLoop parked in the handler at client.go:244 with the test goroutine blocked in writeMessage. TestClientDrainsAcceptedNotificationsAfterTransportEOF catches it.
  • FIFO ordering. Flipping dequeueNotification to pop the tail fails three tests, including order across the 512-message burst.
  • Both backlog limits are real. Dropping either the count check or the byte check in enqueueNotification fails its own test; neither is redundant.
  • Oversized frames. Removing the maxFrameBytes check fails TestReadMessageRejectsOversizedFrame.
  • Post-close admission. Removing the notifyClosed guard fails TestClientDropsNotificationsAfterClose.
  • Closed-client drain. Dropping <-drained in catchUpNotifications fails two manager tests.
  • The dd0a7fa catch-up re-read. Restoring the one-shot snapshot fails TestCatchUpNotificationsFollowsPublishesAcceptedWhileWaiting.
  • markHandled after the handler. Moving it before the handler call fails two tests.
  • The <-s.client.closed wakeups in waitForDiagnostics. Removing them fails two tests.

Worth calling out separately: readMessage on main did make([]byte, contentLength) straight off a peer-supplied header with no ceiling. The 16 MiB cap closes an unbounded-allocation path that predates this PR.

On the macOS failure from the dd0a7fa round — I couldn't reproduce anything like it. go test ./internal/lsp -count=8 is clean, and -count=6 under 3× CPU oversubscription is also clean (285s vs 27s unloaded), which is the shape of load that usually shakes out the timing-sensitive tests here. CI is green on head across all five jobs.

One gap, which is why I'm writing this up rather than just approving silently:

TestPublishBaselineRejectsFrameReadBeforeBaseline doesn't pin the fix it's named forinternal/lsp/manager_test.go:336. The test calls client.stampReceipt() itself and never drives readLoop, so what it actually asserts is the seq comparison, not where the stamp is taken. I moved the stamp from before the json.Unmarshal (client.go:228) down into the enqueue call site — i.e. reverted b0722ff — and the entire package stays green. The P1 from round three can be silently reverted by anyone refactoring readLoop, with no test to stop them.

There's a deterministic way to pin it with no timing dependence: under read-time stamping, a frame that is not a notification still consumes a receipt number ("gaps in it are fine", per your own comment); under enqueue-time stamping it doesn't. So:

client := NewClient(clientReader, io.Discard)
// A response frame: read off the wire, but it never reaches enqueueNotification.
writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "id": 1, "result": nil})
// Poll briefly — ReceiptSeq() must become > 0.

I ran that against both variants: passes on head, fails with receiptSeq = 0 as soon as the stamp moves to enqueue.

Not blocking the merge — the production code is correct, this is only about keeping it that way. Fine by me either in this PR or as a follow-up.

Two things I'm deliberately not re-raising: the unbounded <-drained for a closed client with a third-party SetNotificationHandler (documents.go:265) — the only in-repo handler is session.handleNotification, which parses, records and closes waiter channels, none of which can block, and both jatmn and CodeRabbit have already logged it; and the Client.NotificationSeq comment at documents.go:32, which is already fixed on head.

Regression test for the gap flagged in review pullrequestreview-4834747507
on Gitlawb#759: TestPublishBaselineRejectsFrameReadBeforeBaseline calls
stampReceipt directly and never drives readLoop, so it doesn't pin where
the stamp is taken. A response frame (read but never enqueued as a
notification) distinguishes the two call sites directly.

Co-authored-by: Cursor <cursoragent@cursor.com>

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

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.

fix(lsp): re-entrant notification handlers can deadlock

5 participants