diff --git a/lib/ruby_lsp/addon/process_client.rb b/lib/ruby_lsp/addon/process_client.rb new file mode 100644 index 0000000000..0760df3dd2 --- /dev/null +++ b/lib/ruby_lsp/addon/process_client.rb @@ -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 diff --git a/lib/ruby_lsp/addon/process_server.rb b/lib/ruby_lsp/addon/process_server.rb new file mode 100644 index 0000000000..92dfbec737 --- /dev/null +++ b/lib/ruby_lsp/addon/process_server.rb @@ -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 diff --git a/test/addon/fake_process_server.rb b/test/addon/fake_process_server.rb new file mode 100644 index 0000000000..b41a319472 --- /dev/null +++ b/test/addon/fake_process_server.rb @@ -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 diff --git a/test/addon/process_client_server_test.rb b/test/addon/process_client_server_test.rb new file mode 100644 index 0000000000..4139fa1402 --- /dev/null +++ b/test/addon/process_client_server_test.rb @@ -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