Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fixed false `unused-import` warnings for modules with docstrings
(Closed https://github.com/aviatesk/JETLS.jl/issues/586).

- Fixed server hang when the client terminates without sending an
`exit` notification (e.g. Neovim)
(https://github.com/aviatesk/JETLS.jl/pull/580).

## 2026-03-08

- Commit: [`d32f1cf`](https://github.com/aviatesk/JETLS.jl/commit/d32f1cf)
Expand Down
53 changes: 27 additions & 26 deletions LSP/src/communication.jl
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ close(endpoint)
```
"""
mutable struct Endpoint
in_msg_queue::Channel{Any}
out_msg_queue::Channel{Any}
read_task::Task
write_task::Task
const in_msg_queue::Channel{Any}
const out_msg_queue::Channel{Any}
const read_task::Task
const write_task::Task
@atomic isopen::Bool

function Endpoint(err_handler, in::IO, out::IO)
Expand All @@ -48,16 +48,23 @@ mutable struct Endpoint

local endpoint_ref = Ref{Endpoint}()

read_task = Threads.@spawn :interactive while true
msg = @something try
readlsp(in)
catch err
err_handler(#=isread=#true, err, catch_backtrace())
continue
end break # terminate this task loop when the stream is closed
(!isassigned(endpoint_ref) || isopen(endpoint_ref[])) || break
put!(in_msg_queue, msg)
GC.safepoint()
read_task = Threads.@spawn :interactive begin
while true
msg = @something try
readlsp(in)
catch err
err_handler(#=isread=#true, err, catch_backtrace())
continue
end break # terminate this task loop when the stream is closed
(!isassigned(endpoint_ref) || isopen(endpoint_ref[])) || break
put!(in_msg_queue, msg)
GC.safepoint()
end
# Send a sentinel to unblock `take!` in `iterate` — without this,
# the server loop hangs forever when the input stream closes.
# Guard with `isopen` since `close(endpoint)` may have already
# closed the channel during normal shutdown.
isopen(in_msg_queue) && put!(in_msg_queue, nothing)
Copy link
Owner

Choose a reason for hiding this comment

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

When read_task exits during normal shutdown, close(endpoint) may have already closed in_msg_queue. Without this guard, put! might throw InvalidStateException on the closed channel. The exception is silently swallowed (since read_task is never waited on), but the guard makes the intent explicit and avoids the unnecessary exception.

end

write_task = Threads.@spawn :interactive for msg in out_msg_queue
Expand Down Expand Up @@ -137,7 +144,6 @@ end
to_lsp_json(@nospecialize msg) = JSON3.write(msg)

function Base.close(endpoint::Endpoint)
flush(endpoint)
put!(endpoint.out_msg_queue, nothing) # send a special token to terminate the write task
close(endpoint.out_msg_queue)
wait(endpoint.write_task)
Expand All @@ -151,18 +157,14 @@ end

Base.isopen(endpoint::Endpoint) = @atomic :acquire endpoint.isopen

check_dead_endpoint!(endpoint::Endpoint) = isopen(endpoint) || error("Endpoint is closed")

function Base.flush(endpoint::Endpoint)
check_dead_endpoint!(endpoint)
while isready(endpoint.out_msg_queue)
yield()
end
end

function Base.iterate(endpoint::Endpoint, _=nothing)
isopen(endpoint) || return nothing
return take!(endpoint.in_msg_queue), nothing
msg = take!(endpoint.in_msg_queue)
# `nothing` is a sentinel from `read_task` signaling that the input
# stream has closed (e.g. client process died). End iteration so the
# server loop can proceed to its `finally` cleanup as usual.
msg === nothing && return nothing
return msg, nothing
end

"""
Expand All @@ -181,7 +183,6 @@ This function is non-blocking and returns immediately after queueing the message
- `ErrorException`: If the endpoint is closed
"""
function send(endpoint::Endpoint, @nospecialize(msg::Any))
check_dead_endpoint!(endpoint)
put!(endpoint.out_msg_queue, msg)
nothing
end
Loading