Skip to content

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

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

fix(lsp): dispatch notifications off read loop#759
PierrunoYT wants to merge 5 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 initiate new client calls.
    • Ensured incoming notifications are delivered in exact FIFO order.
    • Improved stability under large notification bursts by decoupling notification handling from the response read/send flow.
    • Updated shutdown behavior so notifications sent after close are dropped, while in-flight notifications still complete.
  • Tests
    • Expanded regression coverage for deadlock prevention, FIFO ordering, burst handling, lossless delivery, and post-close dropping behavior.

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

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: 36de8edc-f10b-4c7b-b8ac-a859012e233a

📥 Commits

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

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

Walkthrough

Client notification handling now uses a FIFO queue and dedicated dispatch goroutine, allowing handlers to make client calls without blocking response processing. Shutdown clears queued notifications, and tests cover ordering, bursts, losslessness, and post-close behavior.

Changes

LSP notification dispatch

Layer / File(s) Summary
Buffered notification dispatch
internal/lsp/client.go
readLoop enqueues server notifications, while notificationLoop drains the queue and invokes handlers asynchronously in FIFO order.
Notification shutdown lifecycle
internal/lsp/client.go, internal/lsp/client_test.go
Client shutdown closes notification dispatch, clears queued work, and prevents notifications sent after close from being retained or handled.
Notification dispatch validation
internal/lsp/client_test.go
Tests verify re-entrant calls, FIFO ordering, response progress during bursts, and lossless queue draining.

Estimated code review effort: 3 (Moderate) | ~25 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: jatmn, gnanam1990

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: moving LSP notification dispatch off the read loop.
Linked Issues check ✅ Passed The PR implements the requested serialized notification worker, preserves ordering, avoids blocking reads, and adds the re-entrant call regression test.
Out of Scope Changes check ✅ Passed The changes stay within the notification-dispatch fix and its shutdown/test coverage, with no clear unrelated additions.
✨ 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!

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