Skip to content

Commit d578615

Browse files
committed
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.
1 parent 8bbc829 commit d578615

File tree

4 files changed

+336
-0
lines changed

4 files changed

+336
-0
lines changed

lib/ruby_lsp/addon/process_client.rb

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
class Addon
6+
class ProcessClient
7+
class InitializationError < StandardError; end
8+
class IncompleteMessageError < StandardError; end
9+
class EmptyMessageError < StandardError; end
10+
11+
MAX_RETRIES = 5
12+
13+
extend T::Sig
14+
extend T::Generic
15+
16+
abstract!
17+
18+
sig { returns(Addon) }
19+
attr_reader :addon
20+
21+
sig { returns(IO) }
22+
attr_reader :stdin
23+
24+
sig { returns(IO) }
25+
attr_reader :stdout
26+
27+
sig { returns(IO) }
28+
attr_reader :stderr
29+
30+
sig { returns(Process::Waiter) }
31+
attr_reader :wait_thread
32+
33+
sig { params(addon: Addon, command: String).void }
34+
def initialize(addon, command)
35+
@addon = T.let(addon, Addon)
36+
@mutex = T.let(Mutex.new, Mutex)
37+
# Spring needs a Process session ID. It uses this ID to "attach" itself to the parent process, so that when the
38+
# parent ends, the spring process ends as well. If this is not set, Spring will throw an error while trying to
39+
# set its own session ID
40+
begin
41+
Process.setpgrp
42+
Process.setsid
43+
rescue Errno::EPERM
44+
# If we can't set the session ID, continue
45+
rescue NotImplementedError
46+
# setpgrp() may be unimplemented on some platform
47+
# https://github.com/Shopify/ruby-lsp-rails/issues/348
48+
end
49+
50+
stdin, stdout, stderr, wait_thread = Bundler.with_original_env do
51+
Open3.popen3(command)
52+
end
53+
54+
@stdin = T.let(stdin, IO)
55+
@stdout = T.let(stdout, IO)
56+
@stderr = T.let(stderr, IO)
57+
@wait_thread = T.let(wait_thread, Process::Waiter)
58+
59+
# for Windows compatibility
60+
@stdin.binmode
61+
@stdout.binmode
62+
@stderr.binmode
63+
64+
log_output("booting server")
65+
count = 0
66+
67+
begin
68+
count += 1
69+
handle_initialize_response(T.must(read_response))
70+
rescue EmptyMessageError
71+
log_output("is retrying initialize (#{count})")
72+
retry if count < MAX_RETRIES
73+
end
74+
75+
log_output("finished booting server")
76+
77+
register_exit_handler
78+
rescue Errno::EPIPE, IncompleteMessageError
79+
raise InitializationError, stderr.read
80+
end
81+
82+
sig { void }
83+
def shutdown
84+
log_output("shutting down server")
85+
send_message("shutdown")
86+
sleep(0.5) # give the server a bit of time to shutdown
87+
[stdin, stdout, stderr].each(&:close)
88+
rescue IOError
89+
# The server connection may have died
90+
force_kill
91+
end
92+
93+
sig { returns(T::Boolean) }
94+
def stopped?
95+
[stdin, stdout, stderr].all?(&:closed?) && !wait_thread.alive?
96+
end
97+
98+
sig { params(message: String).void }
99+
def log_output(message)
100+
$stderr.puts("#{@addon.name} - #{message}")
101+
end
102+
103+
# Notifications are like messages, but one-way, with no response sent back.
104+
sig { params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
105+
def send_notification(request, params = nil) = send_message(request, params)
106+
107+
private
108+
109+
sig do
110+
params(
111+
request: String,
112+
params: T.nilable(T::Hash[Symbol, T.untyped]),
113+
).returns(T.nilable(T::Hash[Symbol, T.untyped]))
114+
end
115+
def make_request(request, params = nil)
116+
send_message(request, params)
117+
read_response
118+
end
119+
120+
sig { overridable.params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
121+
def send_message(request, params = nil)
122+
message = { method: request }
123+
message[:params] = params if params
124+
json = message.to_json
125+
126+
@mutex.synchronize do
127+
@stdin.write("Content-Length: #{json.length}\r\n\r\n", json)
128+
end
129+
rescue Errno::EPIPE
130+
# The server connection died
131+
end
132+
133+
sig { overridable.returns(T.nilable(T::Hash[Symbol, T.untyped])) }
134+
def read_response
135+
raw_response = @mutex.synchronize do
136+
headers = @stdout.gets("\r\n\r\n")
137+
raise IncompleteMessageError unless headers
138+
139+
content_length = headers[/Content-Length: (\d+)/i, 1].to_i
140+
raise EmptyMessageError if content_length.zero?
141+
142+
@stdout.read(content_length)
143+
end
144+
145+
response = JSON.parse(T.must(raw_response), symbolize_names: true)
146+
147+
if response[:error]
148+
log_output("error: " + response[:error])
149+
return
150+
end
151+
152+
response.fetch(:result)
153+
rescue Errno::EPIPE
154+
# The server connection died
155+
nil
156+
end
157+
158+
sig { void }
159+
def force_kill
160+
# Windows does not support the `TERM` signal, so we're forced to use `KILL` here
161+
Process.kill(T.must(Signal.list["KILL"]), @wait_thread.pid)
162+
end
163+
164+
sig { abstract.void }
165+
def register_exit_handler; end
166+
167+
sig { abstract.params(response: T::Hash[Symbol, T.untyped]).void }
168+
def handle_initialize_response(response); end
169+
end
170+
end
171+
end

lib/ruby_lsp/addon/process_server.rb

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
6+
module RubyLsp
7+
class Addon
8+
class ProcessServer
9+
extend T::Sig
10+
extend T::Generic
11+
12+
abstract!
13+
14+
VOID = Object.new
15+
16+
sig { void }
17+
def initialize
18+
$stdin.sync = true
19+
$stdout.sync = true
20+
$stdin.binmode
21+
$stdout.binmode
22+
@running = T.let(true, T.nilable(T::Boolean))
23+
end
24+
25+
sig { void }
26+
def start
27+
initialize_result = generate_initialize_response
28+
$stdout.write("Content-Length: #{initialize_result.length}\r\n\r\n#{initialize_result}")
29+
30+
while @running
31+
headers = $stdin.gets("\r\n\r\n")
32+
json = $stdin.read(headers[/Content-Length: (\d+)/i, 1].to_i)
33+
34+
request = JSON.parse(json, symbolize_names: true)
35+
response = execute(request.fetch(:method), request[:params])
36+
next if response == VOID
37+
38+
json_response = response.to_json
39+
$stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
40+
end
41+
end
42+
43+
sig { abstract.returns(String) }
44+
def generate_initialize_response; end
45+
46+
sig { abstract.params(request: String, params: T.untyped).returns(T.untyped) }
47+
def execute(request, params); end
48+
end
49+
end
50+
end

test/addon/fake_process_server.rb

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "json"
5+
require "ruby_lsp/addon/process_server"
6+
7+
module RubyLsp
8+
class Addon
9+
class FakeProcessServer < ProcessServer
10+
def generate_initialize_response
11+
JSON.dump({ result: { initialized: true } })
12+
end
13+
14+
def execute(request, params)
15+
case request
16+
when "echo"
17+
{ result: { echo_result: params[:message] } }
18+
when "shutdown"
19+
@running = false
20+
{ result: {} }
21+
else
22+
VOID
23+
end
24+
end
25+
end
26+
end
27+
end
28+
29+
RubyLsp::Addon::FakeProcessServer.new.start
+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "test_helper"
5+
require "ruby_lsp/addon/process_client"
6+
7+
module RubyLsp
8+
class Addon
9+
class ProcessClientServerTest < Minitest::Test
10+
class FakeClient < ProcessClient
11+
def initialize(addon)
12+
server_path = File.expand_path("../fake_process_server.rb", __FILE__)
13+
super(addon, "bundle exec ruby #{server_path}")
14+
end
15+
16+
def echo(message)
17+
make_request("echo", { message: message })
18+
end
19+
20+
def send_unknown_request
21+
send_message("unknown_request")
22+
end
23+
24+
def log_output(message)
25+
# No-op for testing to reduce noise
26+
end
27+
28+
private
29+
30+
def handle_initialize_response(response)
31+
raise InitializationError, "Server not initialized" unless response[:initialized]
32+
end
33+
34+
def register_exit_handler
35+
# No-op for testing
36+
end
37+
end
38+
39+
def setup
40+
@addon = create_fake_addon
41+
@client = FakeClient.new(@addon)
42+
end
43+
44+
def teardown
45+
@client.shutdown
46+
assert_predicate(@client, :stopped?, "Client should be stopped after shutdown")
47+
RubyLsp::Addon.addons.clear
48+
RubyLsp::Addon.addon_classes.clear
49+
end
50+
51+
def test_client_server_communication
52+
response = @client.echo("Hello, World!")
53+
assert_equal({ echo_result: "Hello, World!" }, response)
54+
end
55+
56+
def test_server_initialization
57+
# The server is already initialized in setup, so we just need to verify it didn't raise an error
58+
assert_instance_of(FakeClient, @client)
59+
end
60+
61+
def test_server_ignores_unknown_request
62+
@client.send_unknown_request
63+
response = @client.echo("Hey!")
64+
assert_equal({ echo_result: "Hey!" }, response)
65+
end
66+
67+
private
68+
69+
def create_fake_addon
70+
Class.new(Addon) do
71+
def name
72+
"FakeAddon"
73+
end
74+
75+
def activate(global_state, outgoing_queue)
76+
# No-op for testing
77+
end
78+
79+
def deactivate
80+
# No-op for testing
81+
end
82+
end.new
83+
end
84+
end
85+
end
86+
end

0 commit comments

Comments
 (0)