Skip to content

Commit f4f37b4

Browse files
authored
Fallback to NullClient if initializing server fails (#266)
* Fallback to `NullClient` if initializing server fails * Fail gracefully on server initialization error
1 parent 315db8e commit f4f37b4

File tree

6 files changed

+165
-59
lines changed

6 files changed

+165
-59
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ class Addon < ::RubyLsp::Addon
1414

1515
sig { returns(RunnerClient) }
1616
def client
17-
@client ||= T.let(RunnerClient.new, T.nilable(RunnerClient))
17+
@client ||= T.let(RunnerClient.create_client, T.nilable(RunnerClient))
1818
end
1919

2020
sig { override.params(message_queue: Thread::Queue).void }
21-
def activate(message_queue); end
21+
def activate(message_queue)
22+
# Eagerly initialize the client in a thread. This allows the indexing from the Ruby LSP to continue running even
23+
# while we boot large Rails applications in the background
24+
Thread.new { client }
25+
end
2226

2327
sig { override.void }
2428
def deactivate

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,38 @@
44
require "json"
55
require "open3"
66

7-
# NOTE: We should avoid printing to stderr since it causes problems. We never read the standard error pipe
8-
# from the client, so it will become full and eventually hang or crash.
9-
# Instead, return a response with an `error` key.
10-
117
module RubyLsp
128
module Rails
139
class RunnerClient
10+
class << self
11+
extend T::Sig
12+
13+
sig { returns(RunnerClient) }
14+
def create_client
15+
new
16+
rescue Errno::ENOENT, StandardError => e # rubocop:disable Lint/ShadowedException
17+
warn("Ruby LSP Rails failed to initialize server: #{e.message}\n#{e.backtrace&.join("\n")}")
18+
warn("Server dependent features will not be available")
19+
NullClient.new
20+
end
21+
end
22+
23+
class InitializationError < StandardError; end
24+
class IncompleteMessageError < StandardError; end
25+
1426
extend T::Sig
1527

1628
sig { void }
1729
def initialize
30+
# Spring needs a Process session ID. It uses this ID to "attach" itself to the parent process, so that when the
31+
# parent ends, the spring process ends as well. If this is not set, Spring will throw an error while trying to
32+
# set its own session ID
33+
begin
34+
Process.setsid
35+
rescue Errno::EPERM
36+
# If we can't set the session ID, continue
37+
end
38+
1839
stdin, stdout, stderr, wait_thread = Open3.popen3(
1940
"bin/rails",
2041
"runner",
@@ -27,11 +48,20 @@ def initialize
2748
@wait_thread = T.let(wait_thread, Process::Waiter)
2849
@stdin.binmode # for Windows compatibility
2950
@stdout.binmode # for Windows compatibility
51+
52+
warn("Ruby LSP Rails booting server")
53+
read_response
54+
warn("Finished booting Ruby LSP Rails server")
55+
rescue Errno::EPIPE, IncompleteMessageError
56+
raise InitializationError, @stderr.read
3057
end
3158

3259
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
3360
def model(name)
3461
make_request("model", name: name)
62+
rescue IncompleteMessageError
63+
warn("Ruby LSP Rails failed to get model information: #{@stderr.read}")
64+
nil
3565
end
3666

3767
sig { void }
@@ -48,13 +78,18 @@ def stopped?
4878

4979
private
5080

51-
sig { params(request: T.untyped, params: T.untyped).returns(T.untyped) }
81+
sig do
82+
params(
83+
request: String,
84+
params: T.nilable(T::Hash[Symbol, T.untyped]),
85+
).returns(T.nilable(T::Hash[Symbol, T.untyped]))
86+
end
5287
def make_request(request, params = nil)
5388
send_message(request, params)
5489
read_response
5590
end
5691

57-
sig { params(request: T.untyped, params: T.untyped).void }
92+
sig { params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
5893
def send_message(request, params = nil)
5994
message = { method: request }
6095
message[:params] = params if params
@@ -68,8 +103,9 @@ def send_message(request, params = nil)
68103
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
69104
def read_response
70105
headers = @stdout.gets("\r\n\r\n")
71-
raw_response = @stdout.read(T.must(headers)[/Content-Length: (\d+)/i, 1].to_i)
106+
raise IncompleteMessageError unless headers
72107

108+
raw_response = @stdout.read(headers[/Content-Length: (\d+)/i, 1].to_i)
73109
response = JSON.parse(T.must(raw_response), symbolize_names: true)
74110

75111
if response[:error]
@@ -80,5 +116,35 @@ def read_response
80116
response.fetch(:result)
81117
end
82118
end
119+
120+
class NullClient < RunnerClient
121+
extend T::Sig
122+
123+
sig { void }
124+
def initialize # rubocop:disable Lint/MissingSuper
125+
end
126+
127+
sig { override.void }
128+
def shutdown
129+
# no-op
130+
end
131+
132+
sig { override.returns(T::Boolean) }
133+
def stopped?
134+
true
135+
end
136+
137+
private
138+
139+
sig { override.params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
140+
def send_message(request, params = nil)
141+
# no-op
142+
end
143+
144+
sig { override.returns(T.nilable(T::Hash[Symbol, T.untyped])) }
145+
def read_response
146+
# no-op
147+
end
148+
end
83149
end
84150
end

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,63 @@
1919
nil
2020
end
2121

22+
# NOTE: We should avoid printing to stderr since it causes problems. We never read the standard error pipe from the
23+
# client, so it will become full and eventually hang or crash. Instead, return a response with an `error` key.
24+
2225
module RubyLsp
2326
module Rails
2427
class Server
2528
VOID = Object.new
2629

2730
extend T::Sig
2831

32+
sig { void }
33+
def initialize
34+
$stdin.sync = true
35+
$stdout.sync = true
36+
@running = T.let(true, T::Boolean)
37+
end
38+
39+
sig { void }
40+
def start
41+
initialize_result = { result: { message: "ok" } }.to_json
42+
$stdout.write("Content-Length: #{initialize_result.length}\r\n\r\n#{initialize_result}")
43+
44+
while @running
45+
headers = $stdin.gets("\r\n\r\n")
46+
json = $stdin.read(headers[/Content-Length: (\d+)/i, 1].to_i)
47+
48+
request = JSON.parse(json, symbolize_names: true)
49+
response = execute(request.fetch(:method), request[:params])
50+
next if response == VOID
51+
52+
json_response = response.to_json
53+
$stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
54+
end
55+
end
56+
57+
sig do
58+
params(
59+
request: String,
60+
params: T::Hash[Symbol, T.untyped],
61+
).returns(T.any(Object, T::Hash[Symbol, T.untyped]))
62+
end
63+
def execute(request, params = {})
64+
case request
65+
when "shutdown"
66+
@running = false
67+
VOID
68+
when "model"
69+
resolve_database_info_from_model(params.fetch(:name))
70+
else
71+
VOID
72+
end
73+
rescue => e
74+
{ error: e.full_message(highlight: false) }
75+
end
76+
77+
private
78+
2979
sig { params(model_name: String).returns(T::Hash[Symbol, T.untyped]) }
3080
def resolve_database_info_from_model(model_name)
3181
const = ActiveSupport::Inflector.safe_constantize(model_name)
@@ -48,41 +98,7 @@ def resolve_database_info_from_model(model_name)
4898
end
4999
info
50100
rescue => e
51-
{
52-
error: e.message,
53-
}
54-
end
55-
56-
sig { void }
57-
def start
58-
$stdin.sync = true
59-
$stdout.sync = true
60-
61-
running = T.let(true, T::Boolean)
62-
63-
while running
64-
headers = $stdin.gets("\r\n\r\n")
65-
request = $stdin.read(headers[/Content-Length: (\d+)/i, 1].to_i)
66-
67-
json = JSON.parse(request, symbolize_names: true)
68-
request_method = json.fetch(:method)
69-
params = json[:params]
70-
71-
response = case request_method
72-
when "shutdown"
73-
running = false
74-
VOID
75-
when "model"
76-
resolve_database_info_from_model(params.fetch(:name))
77-
else
78-
VOID
79-
end
80-
81-
next if response == VOID
82-
83-
json_response = response.to_json
84-
$stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
85-
end
101+
{ error: e.full_message(highlight: false) }
86102
end
87103
end
88104
end

test/ruby_lsp_rails/hover_test.rb

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,18 +194,22 @@ def hover_on_source(source, position)
194194
executor.instance_variable_get(:@index).index_single(
195195
RubyIndexer::IndexablePath.new(nil, T.must(uri.to_standardized_path)), source
196196
)
197-
response = executor.execute(
198-
{
199-
method: "textDocument/hover",
200-
params: {
201-
textDocument: { uri: uri },
202-
position: position,
197+
198+
response = T.let(nil, T.nilable(RubyLsp::Result))
199+
capture_io do
200+
response = executor.execute(
201+
{
202+
method: "textDocument/hover",
203+
params: {
204+
textDocument: { uri: uri },
205+
position: position,
206+
},
203207
},
204-
},
205-
)
208+
)
209+
end
206210

207-
assert_nil(response.error)
208-
response.response
211+
assert_nil(T.must(response).error)
212+
T.must(response).response
209213
end
210214

211215
def dummy_root

test/ruby_lsp_rails/runner_client_test.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ module RubyLsp
88
module Rails
99
class RunnerClientTest < ActiveSupport::TestCase
1010
setup do
11-
@client = T.let(RunnerClient.new, RunnerClient)
11+
capture_io do
12+
@client = T.let(RunnerClient.new, RunnerClient)
13+
end
1214
end
1315

1416
teardown do
@@ -36,6 +38,20 @@ class RunnerClientTest < ActiveSupport::TestCase
3638
test "returns nil if the request returns a nil response" do
3739
assert_nil @client.model("ApplicationRecord") # ApplicationRecord is abstract
3840
end
41+
42+
test "failing to spawn server creates a null client" do
43+
FileUtils.mv("bin/rails", "bin/rails_backup")
44+
45+
assert_output("", %r{No such file or directory - bin/rails}) do
46+
client = RunnerClient.create_client
47+
48+
assert_instance_of(NullClient, client)
49+
assert_nil(client.model("User"))
50+
assert_predicate(client, :stopped?)
51+
end
52+
ensure
53+
FileUtils.mv("bin/rails_backup", "bin/rails")
54+
end
3955
end
4056
end
4157
end

test/ruby_lsp_rails/server_test.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,24 @@ class ServerTest < ActiveSupport::TestCase
1010
end
1111

1212
test "returns nil if model doesn't exist" do
13-
response = @server.resolve_database_info_from_model("Foo")
13+
response = @server.execute("model", { name: "Foo" })
1414
assert_nil(response.fetch(:result))
1515
end
1616

1717
test "returns nil if class is not a model" do
18-
response = @server.resolve_database_info_from_model("Time")
18+
response = @server.execute("model", { name: "Time" })
1919
assert_nil(response.fetch(:result))
2020
end
2121

2222
test "returns nil if class is an abstract model" do
23-
response = @server.resolve_database_info_from_model("ApplicationRecord")
23+
response = @server.execute("model", { name: "ApplicationRecord" })
2424
assert_nil(response.fetch(:result))
2525
end
2626

2727
test "handles older Rails version which don't have `schema_dump_path`" do
2828
ActiveRecord::Tasks::DatabaseTasks.send(:alias_method, :old_schema_dump_path, :schema_dump_path)
2929
ActiveRecord::Tasks::DatabaseTasks.undef_method(:schema_dump_path)
30-
response = @server.resolve_database_info_from_model("User")
30+
response = @server.execute("model", { name: "User" })
3131
assert_nil(response.fetch(:result)[:schema_file])
3232
ensure
3333
ActiveRecord::Tasks::DatabaseTasks.send(:alias_method, :schema_dump_path, :old_schema_dump_path)

0 commit comments

Comments
 (0)