Skip to content

Commit

Permalink
Allow server to send asynchronous notifications to client (#559)
Browse files Browse the repository at this point in the history
Previously, we were only allowing the server and its runtime add-ons to send simple strings to the stderr pipe, which would become log notifications.

To support initiating progress from the server, we will need to allow any type of LSP notification to be send asynchronously.

This PR changes our current logger thread to become a more general notifier thread, which will read any messages received from the server and push them to the outgoing queue.

To prevent creating a breaking change, I kept the `log_message` method, but changed it to build the right notification expected.
  • Loading branch information
vinistock authored Jan 28, 2025
1 parent 6279b37 commit 7fe045a
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 24 deletions.
27 changes: 24 additions & 3 deletions lib/ruby_lsp/ruby_lsp_rails/runner_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,16 @@ def initialize(outgoing_queue)
end
end

@logger_thread = T.let(
# Responsible for transmitting notifications coming from the server to the outgoing queue, so that we can do
# things such as showing progress notifications initiated by the server
@notifier_thread = T.let(
Thread.new do
while (content = @stderr.gets("\n"))
log_message(content, type: RubyLsp::Constant::MessageType::LOG)
until @stderr.closed?
notification = read_notification

unless @outgoing_queue.closed? || !notification
@outgoing_queue << notification
end
end
rescue IOError
# The server was shutdown and stderr is already closed
Expand Down Expand Up @@ -338,6 +344,21 @@ def read_content_length

length.to_i
end

# Read a server notification from stderr. Only intended to be used by notifier thread
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def read_notification
headers = @stderr.gets("\r\n\r\n")
return unless headers

length = headers[/Content-Length: (\d+)/i, 1]
return unless length

raw_content = @stderr.read(length.to_i)
return unless raw_content

JSON.parse(raw_content, symbolize_names: true)
end
end

class NullClient < RunnerClient
Expand Down
40 changes: 25 additions & 15 deletions lib/ruby_lsp/ruby_lsp_rails/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,10 @@
module RubyLsp
module Rails
module Common
# Write a message to the client. Can be used for sending notifications to the editor
def send_message(message)
json_message = message.to_json
@stdout.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}")
end

# Log a message to the editor's output panel
def log_message(message)
$stderr.puts(message)
# Log a message to the editor's output panel. The type is the number of the message type, which can be found in
# the specification https://microsoft.github.io/language-server-protocol/specification/#messageType
def log_message(message, type: 4)
send_notification({ method: "window/logMessage", params: { type: type, message: message } })
end

# Sends an error result to a request, if the request failed. DO NOT INVOKE THIS METHOD FOR NOTIFICATIONS! Use
Expand Down Expand Up @@ -54,6 +49,20 @@ def with_notification_error_handling(notification_name, &block)
rescue => e
log_message("Request #{notification_name} failed:\n#{e.full_message(highlight: false)}")
end

private

# Write a response message back to the client
def send_message(message)
json_message = message.to_json
@stdout.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}")
end

# Write a notification to the client to be transmitted to the editor
def send_notification(message)
json_message = message.to_json
@stderr.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}")
end
end

class ServerAddon
Expand All @@ -76,16 +85,17 @@ def delegate(name, request, params)
end

# Instantiate all server addons and store them in a hash for easy access after we have discovered the classes
def finalize_registrations!(stdout)
def finalize_registrations!(stdout, stderr)
until @server_addon_classes.empty?
addon = @server_addon_classes.shift.new(stdout)
addon = @server_addon_classes.shift.new(stdout, stderr)
@server_addons[addon.name] = addon
end
end
end

def initialize(stdout)
def initialize(stdout, stderr)
@stdout = stdout
@stderr = stderr
end

def name
Expand All @@ -100,11 +110,11 @@ def execute(request, params)
class Server
include Common

def initialize(stdout: $stdout, override_default_output_device: true)
def initialize(stdout: $stdout, stderr: $stderr, override_default_output_device: true)
# Grab references to the original pipes so that we can change the default output device further down
@stdin = $stdin
@stdout = stdout
@stderr = $stderr
@stderr = stderr
@stdin.sync = true
@stdout.sync = true
@stderr.sync = true
Expand Down Expand Up @@ -169,7 +179,7 @@ def execute(request, params)
when "server_addon/register"
with_notification_error_handling(request) do
require params[:server_addon_path]
ServerAddon.finalize_registrations!(@stdout)
ServerAddon.finalize_registrations!(@stdout, @stderr)
end
when "server_addon/delegate"
server_addon_name = params[:server_addon_name]
Expand Down
10 changes: 5 additions & 5 deletions test/ruby_lsp_rails/runner_client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def name
def execute(request, params)
log_message("Hello!")
send_message({ request:, params: })
send_result({ request: request, params: params })
end
end
RUBY
Expand All @@ -141,16 +141,16 @@ def execute(request, params)
# Finished booting server
pop_log_notification(@outgoing_queue, RubyLsp::Constant::MessageType::LOG)

log = pop_log_notification(@outgoing_queue, RubyLsp::Constant::MessageType::LOG)
log = @outgoing_queue.pop

# Sometimes we get warnings concerning deprecations and they mess up this expectation
3.times do
unless log.params.message.match?(/Hello!/)
log = pop_log_notification(@outgoing_queue, RubyLsp::Constant::MessageType::LOG)
unless log.dig(:params, :message).match?(/Hello!/)
log = @outgoing_queue.pop
end
end

assert_match("Hello!", log.params.message)
assert_match("Hello!", log.dig(:params, :message))
ensure
FileUtils.rm("server_addon.rb")
end
Expand Down
25 changes: 24 additions & 1 deletion test/ruby_lsp_rails/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
class ServerTest < ActiveSupport::TestCase
setup do
@stdout = StringIO.new
@server = RubyLsp::Rails::Server.new(stdout: @stdout, override_default_output_device: false)
@stderr = StringIO.new
@server = RubyLsp::Rails::Server.new(stdout: @stdout, stderr: @stderr, override_default_output_device: false)
end

test "returns nil if model doesn't exist" do
Expand Down Expand Up @@ -229,6 +230,28 @@ def resolve_route_info(requirements)
assert_equal expected, @stdout.string
end

test "log_message sends notification to client" do
@server.log_message("Hello")

expected_notification = {
method: "window/logMessage",
params: { type: 4, message: "Hello" },
}.to_json

assert_equal "Content-Length: #{expected_notification.bytesize}\r\n\r\n#{expected_notification}", @stderr.string
end

test "log_message allows server to define message type" do
@server.log_message("Hello", type: 1)

expected_notification = {
method: "window/logMessage",
params: { type: 1, message: "Hello" },
}.to_json

assert_equal "Content-Length: #{expected_notification.bytesize}\r\n\r\n#{expected_notification}", @stderr.string
end

private

def response
Expand Down

0 comments on commit 7fe045a

Please sign in to comment.