Skip to content

Commit 8b98849

Browse files
committed
Unify all error handling for the server
1 parent cba97f8 commit 8b98849

File tree

3 files changed

+111
-85
lines changed

3 files changed

+111
-85
lines changed

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"rubyLsp.indexing": {
3+
"includedPatterns": [
4+
"test/dummy/**/*.rb"
5+
]
6+
}
7+
}

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 95 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,43 @@ def send_message(message)
1919
# Log a message to the editor's output panel
2020
def log_message(message)
2121
$stderr.puts(message)
22-
send_message({ result: nil })
22+
end
23+
24+
# Sends an error result to a request, if the request failed. DO NOT INVOKE THIS METHOD FOR NOTIFICATIONS! Use
25+
# `log_message` instead, otherwise the client/server communication will go out of sync
26+
def send_error_response(message)
27+
send_message({ error: message })
28+
end
29+
30+
# Sends a result back to the client
31+
def send_result(result)
32+
send_message({ result: result })
33+
end
34+
35+
# Handle possible errors for a request. This should only be used for requests, which means messages that return a
36+
# response back to the client. Errors are returned as an error object back to the client
37+
def with_request_error_handling(request_name, &block)
38+
block.call
39+
rescue ActiveRecord::ConnectionNotEstablished
40+
# Since this is a common problem, we show a specific error message to the user, instead of the full stack trace.
41+
send_error_response("Request #{request_name} failed because database connection was not established.")
42+
rescue ActiveRecord::NoDatabaseError
43+
send_error_response("Request #{request_name} failed because the database does not exist.")
44+
rescue => e
45+
send_error_response("Request #{request_name} failed:\n#{e.full_message(highlight: false)}")
46+
end
47+
48+
# Handle possible errors for a notification. This should only be used for notifications, which means messages that
49+
# do not return a response back to the client. Errors are logged to the editor's output panel
50+
def with_notification_error_handling(notification_name, &block)
51+
block.call
52+
rescue ActiveRecord::ConnectionNotEstablished
53+
# Since this is a common problem, we show a specific error message to the user, instead of the full stack trace.
54+
log_message("Request #{notification_name} failed because database connection was not established.")
55+
rescue ActiveRecord::NoDatabaseError
56+
log_message("Request #{notification_name} failed because the database does not exist.")
57+
rescue => e
58+
log_message("Request #{notification_name} failed:\n#{e.full_message(highlight: false)}")
2359
end
2460
end
2561

@@ -88,11 +124,8 @@ def initialize(stdout: $stdout, override_default_output_device: true)
88124
end
89125

90126
def start
91-
# Load routes if they haven't been loaded yet (see https://github.com/rails/rails/pull/51614).
92-
routes_reloader = ::Rails.application.routes_reloader
93-
routes_reloader.execute_unless_loaded if routes_reloader&.respond_to?(:execute_unless_loaded)
94-
95-
send_message({ result: { message: "ok", root: ::Rails.root.to_s } })
127+
load_routes
128+
send_result({ message: "ok", root: ::Rails.root.to_s })
96129

97130
while @running
98131
headers = @stdin.gets("\r\n\r\n")
@@ -104,41 +137,50 @@ def start
104137
end
105138

106139
def execute(request, params)
107-
request_name = request
108-
request_name = "#{params[:server_addon_name]}##{params[:request_name]}" if request == "server_addon/delegate"
109-
110140
case request
111141
when "shutdown"
112142
@running = false
113143
when "model"
114-
send_message(resolve_database_info_from_model(params.fetch(:name)))
144+
with_request_error_handling(request) do
145+
send_result(resolve_database_info_from_model(params.fetch(:name)))
146+
end
115147
when "association_target_location"
116-
send_message(resolve_association_target(params))
148+
with_request_error_handling(request) do
149+
send_result(resolve_association_target(params))
150+
end
117151
when "pending_migrations_message"
118-
send_message({ result: { pending_migrations_message: pending_migrations_message } })
152+
with_request_error_handling(request) do
153+
send_result({ pending_migrations_message: pending_migrations_message })
154+
end
119155
when "run_migrations"
120-
send_message({ result: run_migrations })
156+
with_request_error_handling(request) do
157+
send_result(run_migrations)
158+
end
121159
when "reload"
122-
::Rails.application.reloader.reload!
160+
with_notification_error_handling(request) do
161+
::Rails.application.reloader.reload!
162+
end
123163
when "route_location"
124-
send_message(route_location(params.fetch(:name)))
164+
with_request_error_handling(request) do
165+
send_result(route_location(params.fetch(:name)))
166+
end
125167
when "route_info"
126-
send_message(resolve_route_info(params))
168+
with_request_error_handling(request) do
169+
send_result(resolve_route_info(params))
170+
end
127171
when "server_addon/register"
128-
require params[:server_addon_path]
129-
ServerAddon.finalize_registrations!(@stdout)
172+
with_notification_error_handling(request) do
173+
require params[:server_addon_path]
174+
ServerAddon.finalize_registrations!(@stdout)
175+
end
130176
when "server_addon/delegate"
131177
server_addon_name = params[:server_addon_name]
132178
request_name = params[:request_name]
179+
180+
# Do not wrap this in error handlers. Server add-ons need to have the flexibility to choose if they want to
181+
# include a response or not as part of error handling, so a blanket approach is not appropriate.
133182
ServerAddon.delegate(server_addon_name, request_name, params.except(:request_name, :server_addon_name))
134183
end
135-
# Since this is a common problem, we show a specific error message to the user, instead of the full stack trace.
136-
rescue ActiveRecord::ConnectionNotEstablished
137-
log_message("Request #{request_name} failed because database connection was not established.")
138-
rescue ActiveRecord::NoDatabaseError
139-
log_message("Request #{request_name} failed because the database does not exist.")
140-
rescue => e
141-
log_message("Request #{request_name} failed:\n" + e.full_message(highlight: false))
142184
end
143185

144186
private
@@ -156,19 +198,15 @@ def resolve_route_info(requirements)
156198
end
157199

158200
source_location = route&.respond_to?(:source_location) && route.source_location
201+
return unless source_location
159202

160-
if source_location
161-
file, _, line = source_location.rpartition(":")
162-
body = {
163-
source_location: [::Rails.root.join(file).to_s, line],
164-
verb: route.verb,
165-
path: route.path.spec.to_s,
166-
}
203+
file, _, line = source_location.rpartition(":")
167204

168-
{ result: body }
169-
else
170-
{ result: nil }
171-
end
205+
{
206+
source_location: [::Rails.root.join(file).to_s, line],
207+
verb: route.verb,
208+
path: route.path.spec.to_s,
209+
}
172210
end
173211

174212
# Older versions of Rails don't support `route_source_locations`.
@@ -182,74 +220,48 @@ def route_location(name)
182220
end
183221

184222
match_data = name.match(/^(.+)(_path|_url)$/)
185-
return { result: nil } unless match_data
223+
return unless match_data
186224

187225
key = match_data[1]
188226

189227
# A token could match the _path or _url pattern, but not be an actual route.
190228
route = ::Rails.application.routes.named_routes.get(key)
191-
return { result: nil } unless route&.source_location
192-
193-
{
194-
result: {
195-
location: ::Rails.root.join(route.source_location).to_s,
196-
},
197-
}
198-
rescue => e
199-
{ error: e.full_message(highlight: false) }
229+
return unless route&.source_location
230+
231+
{ location: ::Rails.root.join(route.source_location).to_s }
200232
end
201233
else
202234
def route_location(name)
203-
{ result: nil }
235+
nil
204236
end
205237
end
206238

207239
def resolve_database_info_from_model(model_name)
208240
const = ActiveSupport::Inflector.safe_constantize(model_name)
209-
unless active_record_model?(const)
210-
return {
211-
result: nil,
212-
}
213-
end
241+
return unless active_record_model?(const)
214242

215243
info = {
216-
result: {
217-
columns: const.columns.map { |column| [column.name, column.type, column.default, column.null] },
218-
primary_keys: Array(const.primary_key),
219-
},
244+
columns: const.columns.map { |column| [column.name, column.type, column.default, column.null] },
245+
primary_keys: Array(const.primary_key),
220246
}
221247

222248
if ActiveRecord::Tasks::DatabaseTasks.respond_to?(:schema_dump_path)
223-
info[:result][:schema_file] =
224-
ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(const.connection.pool.db_config)
225-
249+
info[:schema_file] = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(const.connection.pool.db_config)
226250
end
251+
227252
info
228-
rescue => e
229-
{ error: e.full_message(highlight: false) }
230253
end
231254

232255
def resolve_association_target(params)
233256
const = ActiveSupport::Inflector.safe_constantize(params[:model_name])
234-
unless active_record_model?(const)
235-
return {
236-
result: nil,
237-
}
238-
end
257+
return unless active_record_model?(const)
239258

240259
association_klass = const.reflect_on_association(params[:association_name].intern).klass
241-
242260
source_location = Object.const_source_location(association_klass.to_s)
243261

244-
{
245-
result: {
246-
location: source_location.first + ":" + source_location.second.to_s,
247-
},
248-
}
262+
{ location: source_location.first + ":" + source_location.second.to_s }
249263
rescue NameError
250-
{
251-
result: nil,
252-
}
264+
nil
253265
end
254266

255267
def active_record_model?(const)
@@ -282,6 +294,14 @@ def run_migrations
282294

283295
{ message: stdout, status: status.exitstatus }
284296
end
297+
298+
def load_routes
299+
with_notification_error_handling("initial_load_routes") do
300+
# Load routes if they haven't been loaded yet (see https://github.com/rails/rails/pull/51614).
301+
routes_reloader = ::Rails.application.routes_reloader
302+
routes_reloader.execute_unless_loaded if routes_reloader&.respond_to?(:execute_unless_loaded)
303+
end
304+
end
285305
end
286306
end
287307
end

test/ruby_lsp_rails/server_test.rb

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -200,22 +200,21 @@ def resolve_route_info(requirements)
200200

201201
test "shows error if there is a database connection error" do
202202
@server.expects(:pending_migrations_message).raises(ActiveRecord::ConnectionNotEstablished)
203+
@server.execute("pending_migrations_message", {})
203204

204-
_, stderr = capture_subprocess_io do
205-
@server.execute("pending_migrations_message", {})
206-
end
207-
assert_equal(stderr, "Request pending_migrations_message failed because database connection was not established.\n")
208-
assert_equal({ result: nil }, response)
205+
assert_equal(
206+
{ error: "Request pending_migrations_message failed because database connection was not established." }, response
207+
)
209208
end
210209

211210
test "shows error if database does not exist" do
212211
@server.expects(:pending_migrations_message).raises(ActiveRecord::NoDatabaseError)
212+
@server.execute("pending_migrations_message", {})
213213

214-
_, stderr = capture_subprocess_io do
215-
@server.execute("pending_migrations_message", {})
216-
end
217-
assert_equal(stderr, "Request pending_migrations_message failed because the database does not exist.\n")
218-
assert_equal({ result: nil }, response)
214+
assert_equal(
215+
{ error: "Request pending_migrations_message failed because the database does not exist." },
216+
response,
217+
)
219218
end
220219

221220
private

0 commit comments

Comments
 (0)