-
Notifications
You must be signed in to change notification settings - Fork 163
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add ProcessClient and ProcessServer classes for addon framework
This commit introduces two new classes to enhance the addon framework: 1. ProcessClient: Manages communication with addon servers, handling initialization, message sending/receiving, and shutdown. 2. ProcessServer: Provides a base class for addon servers to handle requests and responses. These classes facilitate better separation of concerns and provide a structured approach for addons to communicate with the Ruby LSP server.
- Loading branch information
Showing
4 changed files
with
330 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
# typed: strict | ||
# frozen_string_literal: true | ||
|
||
module RubyLsp | ||
class Addon | ||
class ProcessClient | ||
class InitializationError < StandardError; end | ||
class IncompleteMessageError < StandardError; end | ||
class EmptyMessageError < StandardError; end | ||
|
||
MAX_RETRIES = 5 | ||
|
||
extend T::Sig | ||
extend T::Generic | ||
|
||
abstract! | ||
|
||
sig { returns(Addon) } | ||
attr_reader :addon | ||
|
||
sig { returns(IO) } | ||
attr_reader :stdin | ||
|
||
sig { returns(IO) } | ||
attr_reader :stdout | ||
|
||
sig { returns(IO) } | ||
attr_reader :stderr | ||
|
||
sig { returns(Process::Waiter) } | ||
attr_reader :wait_thread | ||
|
||
sig { params(addon: Addon, command: String).void } | ||
def initialize(addon, command) | ||
@addon = T.let(addon, Addon) | ||
@mutex = T.let(Mutex.new, Mutex) | ||
# Spring needs a Process session ID. It uses this ID to "attach" itself to the parent process, so that when the | ||
# parent ends, the spring process ends as well. If this is not set, Spring will throw an error while trying to | ||
# set its own session ID | ||
begin | ||
Process.setpgrp | ||
Process.setsid | ||
rescue Errno::EPERM | ||
# If we can't set the session ID, continue | ||
rescue NotImplementedError | ||
# setpgrp() may be unimplemented on some platform | ||
# https://github.com/Shopify/ruby-lsp-rails/issues/348 | ||
end | ||
|
||
stdin, stdout, stderr, wait_thread = Bundler.with_original_env do | ||
Open3.popen3(command) | ||
end | ||
|
||
@stdin = T.let(stdin, IO) | ||
@stdout = T.let(stdout, IO) | ||
@stderr = T.let(stderr, IO) | ||
@wait_thread = T.let(wait_thread, Process::Waiter) | ||
|
||
# for Windows compatibility | ||
@stdin.binmode | ||
@stdout.binmode | ||
@stderr.binmode | ||
|
||
log_output("booting server") | ||
count = 0 | ||
|
||
begin | ||
count += 1 | ||
handle_initialize_response(T.must(read_response)) | ||
rescue EmptyMessageError | ||
log_output("is retrying initialize (#{count})") | ||
retry if count < MAX_RETRIES | ||
end | ||
|
||
log_output("finished booting server") | ||
|
||
register_exit_handler | ||
rescue Errno::EPIPE, IncompleteMessageError | ||
raise InitializationError, stderr.read | ||
end | ||
|
||
sig { void } | ||
def shutdown | ||
log_output("shutting down server") | ||
send_message("shutdown") | ||
sleep(0.5) # give the server a bit of time to shutdown | ||
[stdin, stdout, stderr].each(&:close) | ||
rescue IOError | ||
# The server connection may have died | ||
force_kill | ||
end | ||
|
||
sig { returns(T::Boolean) } | ||
def stopped? | ||
[stdin, stdout, stderr].all?(&:closed?) && !wait_thread.alive? | ||
end | ||
|
||
sig { params(message: String).void } | ||
def log_output(message) | ||
$stderr.puts("#{@addon.name} - #{message}") | ||
end | ||
|
||
# Notifications are like messages, but one-way, with no response sent back. | ||
sig { params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void } | ||
def send_notification(request, params = nil) = send_message(request, params) | ||
|
||
private | ||
|
||
sig do | ||
params( | ||
request: String, | ||
params: T.nilable(T::Hash[Symbol, T.untyped]), | ||
).returns(T.nilable(T::Hash[Symbol, T.untyped])) | ||
end | ||
def make_request(request, params = nil) | ||
send_message(request, params) | ||
read_response | ||
end | ||
|
||
sig { overridable.params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void } | ||
def send_message(request, params = nil) | ||
message = { method: request } | ||
message[:params] = params if params | ||
json = message.to_json | ||
|
||
@mutex.synchronize do | ||
@stdin.write("Content-Length: #{json.length}\r\n\r\n", json) | ||
end | ||
rescue Errno::EPIPE | ||
# The server connection died | ||
end | ||
|
||
sig { overridable.returns(T.nilable(T::Hash[Symbol, T.untyped])) } | ||
def read_response | ||
raw_response = @mutex.synchronize do | ||
headers = @stdout.gets("\r\n\r\n") | ||
raise IncompleteMessageError unless headers | ||
|
||
content_length = headers[/Content-Length: (\d+)/i, 1].to_i | ||
raise EmptyMessageError if content_length.zero? | ||
|
||
@stdout.read(content_length) | ||
end | ||
|
||
response = JSON.parse(T.must(raw_response), symbolize_names: true) | ||
|
||
if response[:error] | ||
log_output("error: " + response[:error]) | ||
return | ||
end | ||
|
||
response.fetch(:result) | ||
rescue Errno::EPIPE | ||
# The server connection died | ||
nil | ||
end | ||
|
||
sig { void } | ||
def force_kill | ||
# Windows does not support the `TERM` signal, so we're forced to use `KILL` here | ||
Process.kill(T.must(Signal.list["KILL"]), @wait_thread.pid) | ||
end | ||
|
||
sig { abstract.void } | ||
def register_exit_handler; end | ||
|
||
sig { abstract.params(response: T::Hash[Symbol, T.untyped]).void } | ||
def handle_initialize_response(response); end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
# typed: strict | ||
# frozen_string_literal: true | ||
|
||
module RubyLsp | ||
class Addon | ||
class ProcessServer | ||
extend T::Sig | ||
extend T::Generic | ||
|
||
abstract! | ||
|
||
VOID = Object.new | ||
|
||
sig { void } | ||
def initialize | ||
$stdin.sync = true | ||
$stdout.sync = true | ||
$stdin.binmode | ||
$stdout.binmode | ||
@running = T.let(true, T.nilable(T::Boolean)) | ||
end | ||
|
||
sig { void } | ||
def start | ||
initialize_result = generate_initialize_response | ||
$stdout.write("Content-Length: #{initialize_result.length}\r\n\r\n#{initialize_result}") | ||
|
||
while @running | ||
headers = $stdin.gets("\r\n\r\n") | ||
json = $stdin.read(headers[/Content-Length: (\d+)/i, 1].to_i) | ||
|
||
request = JSON.parse(json, symbolize_names: true) | ||
response = execute(request.fetch(:method), request[:params]) | ||
next if response == VOID | ||
|
||
json_response = response.to_json | ||
$stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}") | ||
end | ||
end | ||
|
||
sig { abstract.returns(String) } | ||
def generate_initialize_response; end | ||
|
||
sig { abstract.params(request: String, params: T.untyped).returns(T.untyped) } | ||
def execute(request, params); end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
# typed: true | ||
# frozen_string_literal: true | ||
|
||
require "sorbet-runtime" | ||
require "json" | ||
require "ruby_lsp/addon/process_server" | ||
|
||
module RubyLsp | ||
class Addon | ||
class FakeProcessServer < ProcessServer | ||
def generate_initialize_response | ||
JSON.dump({ result: { initialized: true } }) | ||
end | ||
|
||
def execute(request, params) | ||
case request | ||
when "echo" | ||
{ result: { echo_result: params[:message] } } | ||
when "shutdown" | ||
@running = false | ||
{ result: {} } | ||
else | ||
VOID | ||
end | ||
end | ||
end | ||
end | ||
end | ||
|
||
RubyLsp::Addon::FakeProcessServer.new.start |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
# typed: true | ||
# frozen_string_literal: true | ||
|
||
require "test_helper" | ||
require "ruby_lsp/addon/process_client" | ||
|
||
module RubyLsp | ||
class Addon | ||
class ProcessClientServerTest < Minitest::Test | ||
class FakeAddon < Addon | ||
def name | ||
"FakeAddon" | ||
end | ||
|
||
def activate(global_state, outgoing_queue) | ||
# No-op for testing | ||
end | ||
|
||
def deactivate | ||
# No-op for testing | ||
end | ||
end | ||
|
||
class FakeClient < ProcessClient | ||
def initialize(addon) | ||
server_path = File.expand_path("../fake_process_server.rb", __FILE__) | ||
super(addon, "bundle exec ruby #{server_path}") | ||
end | ||
|
||
def echo(message) | ||
make_request("echo", { message: message }) | ||
end | ||
|
||
def send_unknown_request | ||
send_message("unknown_request") | ||
end | ||
|
||
def log_output(message) | ||
# No-op for testing to reduce noise | ||
end | ||
|
||
private | ||
|
||
def handle_initialize_response(response) | ||
raise InitializationError, "Server not initialized" unless response[:initialized] | ||
end | ||
|
||
def register_exit_handler | ||
# No-op for testing | ||
end | ||
end | ||
|
||
def setup | ||
@addon = FakeAddon.new | ||
@client = FakeClient.new(@addon) | ||
end | ||
|
||
def teardown | ||
@client.shutdown | ||
assert_predicate(@client, :stopped?, "Client should be stopped after shutdown") | ||
RubyLsp::Addon.addons.clear | ||
end | ||
|
||
def test_client_server_communication | ||
response = @client.echo("Hello, World!") | ||
assert_equal({ echo_result: "Hello, World!" }, response) | ||
end | ||
|
||
def test_server_initialization | ||
# The server is already initialized in setup, so we just need to verify it didn't raise an error | ||
assert_instance_of(FakeClient, @client) | ||
end | ||
|
||
def test_server_ignores_unknown_request | ||
@client.send_unknown_request | ||
response = @client.echo("Hey!") | ||
assert_equal({ echo_result: "Hey!" }, response) | ||
end | ||
end | ||
end | ||
end |