Skip to content

Commit 087a36e

Browse files
committed
Fail gracefully on server initialization error
1 parent 35f8e1e commit 087a36e

File tree

4 files changed

+79
-42
lines changed

4 files changed

+79
-42
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ def client
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: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,28 @@ class << self
1414
def create_client
1515
new
1616
rescue Errno::ENOENT, StandardError => e # rubocop:disable Lint/ShadowedException
17-
warn("Ruby LSP Rails failed to initialize server: #{e.message}")
17+
warn("Ruby LSP Rails failed to initialize server: #{e.message}\n#{e.backtrace&.join("\n")}")
1818
warn("Server dependent features will not be available")
1919
NullClient.new
2020
end
2121
end
2222

23+
class InitializationError < StandardError; end
24+
class IncompleteMessageError < StandardError; end
25+
2326
extend T::Sig
2427

2528
sig { void }
2629
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+
2739
stdin, stdout, stderr, wait_thread = Open3.popen3(
2840
"bin/rails",
2941
"runner",
@@ -36,11 +48,18 @@ def initialize
3648
@wait_thread = T.let(wait_thread, Process::Waiter)
3749
@stdin.binmode # for Windows compatibility
3850
@stdout.binmode # for Windows compatibility
51+
52+
read_response
53+
rescue Errno::EPIPE, IncompleteMessageError
54+
raise InitializationError, @stderr.read
3955
end
4056

4157
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
4258
def model(name)
4359
make_request("model", name: name)
60+
rescue IncompleteMessageError
61+
warn("Ruby LSP Rails failed to get model information: #{@stderr.read}")
62+
nil
4463
end
4564

4665
sig { void }
@@ -82,8 +101,9 @@ def send_message(request, params = nil)
82101
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
83102
def read_response
84103
headers = @stdout.gets("\r\n\r\n")
85-
raw_response = @stdout.read(T.must(headers)[/Content-Length: (\d+)/i, 1].to_i)
104+
raise IncompleteMessageError unless headers
86105

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

89109
if response[:error]

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,53 @@ class Server
2929

3030
extend T::Sig
3131

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+
private
58+
59+
sig do
60+
params(
61+
request: String,
62+
params: T::Hash[Symbol, T.untyped],
63+
).returns(T.any(Object, T::Hash[Symbol, T.untyped]))
64+
end
65+
def execute(request, params = {})
66+
case request
67+
when "shutdown"
68+
@running = false
69+
VOID
70+
when "model"
71+
resolve_database_info_from_model(params.fetch(:name))
72+
else
73+
VOID
74+
end
75+
rescue => e
76+
{ error: "#{e.message}\n#{e.backtrace&.join("\n")}" }
77+
end
78+
3279
sig { params(model_name: String).returns(T::Hash[Symbol, T.untyped]) }
3380
def resolve_database_info_from_model(model_name)
3481
const = ActiveSupport::Inflector.safe_constantize(model_name)
@@ -51,41 +98,7 @@ def resolve_database_info_from_model(model_name)
5198
end
5299
info
53100
rescue => e
54-
{
55-
error: e.message,
56-
}
57-
end
58-
59-
sig { void }
60-
def start
61-
$stdin.sync = true
62-
$stdout.sync = true
63-
64-
running = T.let(true, T::Boolean)
65-
66-
while running
67-
headers = $stdin.gets("\r\n\r\n")
68-
request = $stdin.read(headers[/Content-Length: (\d+)/i, 1].to_i)
69-
70-
json = JSON.parse(request, symbolize_names: true)
71-
request_method = json.fetch(:method)
72-
params = json[:params]
73-
74-
response = case request_method
75-
when "shutdown"
76-
running = false
77-
VOID
78-
when "model"
79-
resolve_database_info_from_model(params.fetch(:name))
80-
else
81-
VOID
82-
end
83-
84-
next if response == VOID
85-
86-
json_response = response.to_json
87-
$stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
88-
end
101+
{ error: e.message }
89102
end
90103
end
91104
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.send(:resolve_database_info_from_model, "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.send(:resolve_database_info_from_model, "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.send(:resolve_database_info_from_model, "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.send(:resolve_database_info_from_model, "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)