Skip to content

Commit

Permalink
Add ProcessClient and ProcessServer classes for addon framework
Browse files Browse the repository at this point in the history
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
st0012 committed Sep 2, 2024
1 parent 8bbc829 commit 145b51d
Show file tree
Hide file tree
Showing 4 changed files with 330 additions and 0 deletions.
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
48 changes: 48 additions & 0 deletions lib/ruby_lsp/addon/process_server.rb
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
30 changes: 30 additions & 0 deletions test/addon/fake_process_server.rb
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
81 changes: 81 additions & 0 deletions test/addon/process_client_server_test.rb
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

0 comments on commit 145b51d

Please sign in to comment.