Skip to content

Commit 145b51d

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 145b51d

File tree

4 files changed

+330
-0
lines changed

4 files changed

+330
-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

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

test/addon/fake_process_server.rb

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

0 commit comments

Comments
 (0)