Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add addon client and server base classes #2501

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
171 changes: 171 additions & 0 deletions lib/ruby_lsp/addon/process_client.rb
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
50 changes: 50 additions & 0 deletions lib/ruby_lsp/addon/process_server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
Copy link
Contributor

Choose a reason for hiding this comment

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

We had some issues in the past when requiring sorbet-runtime within the Rails side. I can't recall the exact cause, but we might need to be wary of that or do some extra checking.


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
29 changes: 29 additions & 0 deletions test/addon/fake_process_server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# typed: true
# frozen_string_literal: true

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
Copy link
Contributor

Choose a reason for hiding this comment

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

We shouldn't need the VOID stuff any more now that Shopify/ruby-lsp-rails#453 is merged.

end
end
end
end
end

RubyLsp::Addon::FakeProcessServer.new.start
86 changes: 86 additions & 0 deletions test/addon/process_client_server_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# typed: true
# frozen_string_literal: true

require "test_helper"
require "ruby_lsp/addon/process_client"

module RubyLsp
class Addon
class ProcessClientServerTest < Minitest::Test
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 = create_fake_addon
@client = FakeClient.new(@addon)
end

def teardown
@client.shutdown
assert_predicate(@client, :stopped?, "Client should be stopped after shutdown")
RubyLsp::Addon.addons.clear
RubyLsp::Addon.addon_classes.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

private

def create_fake_addon
Class.new(Addon) do
def name
"FakeAddon"
end

def activate(global_state, outgoing_queue)
# No-op for testing
end

def deactivate
# No-op for testing
end
end.new
end
end
end
end
Loading