Skip to content

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

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

fix(lsp): dispatch notifications off read loop#759
PierrunoYT wants to merge 2 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
    • Improved client notification handling to prevent deadlocks when notification handlers initiate requests.
    • Preserved the order in which incoming notifications are processed.
    • Improved responsiveness by processing notifications through a dedicated queue.

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 on lines 178 to 183
case msg.Method != "":
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler != nil {
handler(msg.Method, msg.Params)
select {
case c.notify <- notification{method: msg.Method, params: msg.Params}:
case <-c.closed:
return
}
Comment on lines +284 to +288
func TestClientNotificationHandlersPreserveOrder(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
defer client.Close()
defer serverWriter.Close()
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PierrunoYT, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9df71d8f-33b0-4745-91f4-7a6cc79efb86

📥 Commits

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

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

Walkthrough

Client notification handling now uses a buffered queue and dedicated dispatch goroutine, allowing handlers to make client calls while preserving notification order. Tests cover re-entrant calls and ordered delivery.

Changes

LSP notification dispatch

Layer / File(s) Summary
Buffered notification dispatch
internal/lsp/client.go
Notifications are queued from readLoop and processed by notificationLoop, which invokes handlers until client shutdown.
Notification dispatch validation
internal/lsp/client_test.go
Tests verify re-entrant client calls from handlers and preservation of notification order.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LSPServer
  participant readLoop
  participant notificationLoop
  participant NotificationHandler
  LSPServer->>readLoop: Send notification
  readLoop->>notificationLoop: Queue method and params
  notificationLoop->>NotificationHandler: Invoke handler
  NotificationHandler->>readLoop: Issue Client.Call
  readLoop-->>NotificationHandler: Return response
Loading

Suggested reviewers: copilot, gnanam1990

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: moving LSP notifications off the read loop.
Linked Issues check ✅ Passed The changes match #758 by queueing notifications, preserving order, and adding a regression test for re-entrant Client.Call.
Out of Scope Changes check ✅ Passed The added tests and client queueing are directly in scope for the deadlock fix and do not introduce unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 on lines +230 to +282
func TestClientNotificationHandlerCanCallClient(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
client := NewClient(clientReader, clientWriter)
defer client.Close()
defer serverWriter.Close()
defer clientWriter.Close()

serverDone := make(chan error, 1)
go func() {
body, err := readMessage(bufio.NewReader(serverReader))
if err != nil {
serverDone <- err
return
}
var request incomingMessage
if err := json.Unmarshal(body, &request); err != nil {
serverDone <- err
return
}
serverDone <- writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]bool{"applied": true},
})
}()

handlerDone := make(chan error, 1)
client.SetNotificationHandler(func(_ string, _ json.RawMessage) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := client.Call(ctx, "workspace/applyEdit", nil)
handlerDone <- err
})
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": "workspace/requestEdit",
}); err != nil {
t.Fatal(err)
}

select {
case err := <-handlerDone:
if err != nil {
t.Fatalf("notification handler call failed: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("notification handler deadlocked waiting for its response")
}
if err := <-serverDone; err != nil {
t.Fatalf("server failed: %v", err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ensure tests are run under the race detector.

As per coding guidelines, please ensure that these new concurrent tests are executed with the Go race detector (e.g., go test -race ./...). Since the PR objectives noted that environment limitations prevented it from running, it's crucial to verify this locally or in a supported CI environment to confirm the synchronization around notificationLoop and Client.Call is completely race-free.

🤖 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_test.go` around lines 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.

Source: Coding guidelines

Comment thread internal/lsp/client.go

@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.

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

3 participants