Skip to content

Commit fab50c8

Browse files
authored
Check if bundle is valid before restarting (#3066)
### Motivation The more we restart the server, the higher the risk that something related to Bundler may fail and prevent users from having a smooth experience. I think we can be a bit smarter about when we restart the server by checking if composing the bundle is successful before we try. If we know that composing fails, it's better to continue running the current version of the server than crashing. ### Implementation The proposal is to add a custom request that allows the client to ask the server if composing the bundle succeeds. That way, if there's a lockfile update, we can first check if the update produces a valid bundle. If we couldn't compose the bundle, then we avoid restarting. Otherwise, we trigger the restart, but skip composing the bundle since we just did that ahead of time - saving time for the user and allow them to skip directly to indexing. ### Note This solution doesn't fully solve #1458, but it does move the needle a little bit at least for VS Code. The big challenge in moving lockfile watching to the server is how to transfer the state of the server currently running to the new instance we launch. For example, when we receive a notification that the lockfile was changed, we would need to: 1. Compose the new bundle 2. Replace the current process with a new one, using the new bundle (via `exec`) 3. Transfer the state of the previous process to the new one. We need all configurations that were applied during initialize (think the global state), the state of all current documents (the store object) and other pieces of state that exist in the version of the server running before the relaunch I have not yet figured out how to achieve 3. ### Automated Tests Added tests.
1 parent 0650829 commit fab50c8

8 files changed

+143
-11
lines changed

exe/ruby-lsp-launcher

+24-7
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@
88

99
setup_error = nil
1010
install_error = nil
11+
reboot = false
1112

12-
# Read the initialize request before even starting the server. We need to do this to figure out the workspace URI.
13-
# Editors are not required to spawn the language server process on the same directory as the workspace URI, so we need
14-
# to ensure that we're setting up the bundle in the right place
15-
$stdin.binmode
16-
headers = $stdin.gets("\r\n\r\n")
17-
content_length = headers[/Content-Length: (\d+)/i, 1].to_i
18-
raw_initialize = $stdin.read(content_length)
13+
workspace_uri = ARGV.first
14+
15+
raw_initialize = if workspace_uri && !workspace_uri.start_with?("--")
16+
# If there's an argument without `--`, then it's the server asking to compose the bundle and passing to this
17+
# executable the workspace URI. We can't require gems at this point, so we built a fake initialize request manually
18+
reboot = true
19+
"{\"params\":{\"workspaceFolders\":[{\"uri\":\"#{workspace_uri}\"}]}}"
20+
else
21+
# Read the initialize request before even starting the server. We need to do this to figure out the workspace URI.
22+
# Editors are not required to spawn the language server process on the same directory as the workspace URI, so we need
23+
# to ensure that we're setting up the bundle in the right place
24+
$stdin.binmode
25+
headers = $stdin.gets("\r\n\r\n")
26+
content_length = headers[/Content-Length: (\d+)/i, 1].to_i
27+
$stdin.read(content_length)
28+
end
1929

2030
# Compose the Ruby LSP bundle in a forked process so that we can require gems without polluting the main process
2131
# `$LOAD_PATH` and `Gem.loaded_specs`. Windows doesn't support forking, so we need a separate path to support it
@@ -91,6 +101,13 @@ rescue StandardError => e
91101
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
92102
end
93103

104+
# When performing a lockfile re-boot, this executable is invoked to set up the composed bundle ahead of time. In this
105+
# flow, we are not booting the LSP yet, just checking if the bundle is valid before rebooting
106+
if reboot
107+
# Use the exit status to signal to the server if composing the bundle succeeded
108+
exit(install_error || setup_error ? 1 : 0)
109+
end
110+
94111
# Now that the bundle is set up, we can begin actually launching the server. Note that `Bundler.setup` will have already
95112
# configured the load path using the version of the Ruby LSP present in the composed bundle. Do not push any Ruby LSP
96113
# paths into the load path manually or we may end up requiring the wrong version of the gem

lib/ruby_lsp/server.rb

+28
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ def process_message(message)
106106
end,
107107
),
108108
)
109+
when "rubyLsp/composeBundle"
110+
compose_bundle(message)
109111
when "$/cancelRequest"
110112
@global_state.synchronize { @cancelled_requests << message[:params][:id] }
111113
when nil
@@ -283,6 +285,7 @@ def run_initialize(message)
283285
document_range_formatting_provider: true,
284286
experimental: {
285287
addon_detection: true,
288+
compose_bundle: true,
286289
},
287290
),
288291
serverInfo: {
@@ -1282,5 +1285,30 @@ def window_show_message_request(message)
12821285

12831286
addon.handle_window_show_message_response(result[:title])
12841287
end
1288+
1289+
sig { params(message: T::Hash[Symbol, T.untyped]).void }
1290+
def compose_bundle(message)
1291+
already_composed_path = File.join(@global_state.workspace_path, ".ruby-lsp", "bundle_is_composed")
1292+
command = "#{Gem.ruby} #{File.expand_path("../../exe/ruby-lsp-launcher", __dir__)} #{@global_state.workspace_uri}"
1293+
id = message[:id]
1294+
1295+
# We compose the bundle in a thread so that the LSP continues to work while we're checking for its validity. Once
1296+
# we return the response back to the editor, then the restart is triggered
1297+
Thread.new do
1298+
send_log_message("Recomposing the bundle ahead of restart")
1299+
pid = Process.spawn(command)
1300+
_, status = Process.wait2(pid)
1301+
1302+
if status&.exitstatus == 0
1303+
# Create a signal for the restart that it can skip composing the bundle and launch directly
1304+
FileUtils.touch(already_composed_path)
1305+
send_message(Result.new(id: id, response: { success: true }))
1306+
else
1307+
# This special error code makes the extension avoid restarting in case we already know that the composed
1308+
# bundle is not valid
1309+
send_message(Error.new(id: id, code: BUNDLE_COMPOSE_FAILED_CODE, message: "Failed to compose bundle"))
1310+
end
1311+
end
1312+
end
12851313
end
12861314
end

lib/ruby_lsp/setup_bundler.rb

+18
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def initialize(project_path, **options)
5757
@lockfile_hash_path = T.let(@custom_dir + "main_lockfile_hash", Pathname)
5858
@last_updated_path = T.let(@custom_dir + "last_updated", Pathname)
5959
@error_path = T.let(@custom_dir + "install_error", Pathname)
60+
@already_composed_path = T.let(@custom_dir + "bundle_is_composed", Pathname)
6061

6162
dependencies, bundler_version = load_dependencies
6263
@dependencies = T.let(dependencies, T::Hash[String, T.untyped])
@@ -71,6 +72,23 @@ def initialize(project_path, **options)
7172
def setup!
7273
raise BundleNotLocked if !@launcher && @gemfile&.exist? && !@lockfile&.exist?
7374

75+
# If the bundle was composed ahead of time using our custom `rubyLsp/composeBundle` request, then we can skip the
76+
# entire process and just return the composed environment
77+
if @already_composed_path.exist?
78+
$stderr.puts("Ruby LSP> Composed bundle was set up ahead of time. Skipping...")
79+
@already_composed_path.delete
80+
81+
env = bundler_settings_as_env
82+
env["BUNDLE_GEMFILE"] = @custom_gemfile.exist? ? @custom_gemfile.to_s : @gemfile.to_s
83+
84+
if env["BUNDLE_PATH"]
85+
env["BUNDLE_PATH"] = File.expand_path(env["BUNDLE_PATH"], @project_path)
86+
end
87+
88+
env["BUNDLER_VERSION"] = @bundler_version.to_s if @bundler_version
89+
return env
90+
end
91+
7492
# Automatically create and ignore the .ruby-lsp folder for users
7593
@custom_dir.mkpath unless @custom_dir.exist?
7694
ignore_file = @custom_dir + ".gitignore"

lib/ruby_lsp/utils.rb

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class DelegateRequestError < StandardError
3737
CODE = -32900
3838
end
3939

40+
BUNDLE_COMPOSE_FAILED_CODE = -33000
41+
4042
# A notification to be sent to the client
4143
class Message
4244
extend T::Sig

project-words

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dont
2626
eglot
2727
Eglot
2828
eruby
29+
exitstatus
2930
EXTGLOB
3031
fakehome
3132
FIXEDENCODING

test/server_test.rb

+23
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,29 @@ def test_rubocop_config_changes_trigger_workspace_diagnostic_refresh
10331033
assert_equal("workspace/diagnostic/refresh", request.method)
10341034
end
10351035

1036+
def test_compose_bundle_creates_file_to_skip_next_compose
1037+
Dir.mktmpdir do |dir|
1038+
Dir.chdir(dir) do
1039+
@server.process_message({
1040+
id: 1,
1041+
method: "initialize",
1042+
params: {
1043+
initializationOptions: {},
1044+
capabilities: { general: { positionEncodings: ["utf-8"] } },
1045+
workspaceFolders: [{ uri: URI::Generic.from_path(path: dir).to_s }],
1046+
},
1047+
})
1048+
1049+
capture_subprocess_io do
1050+
@server.process_message({ id: 2, method: "rubyLsp/composeBundle" })
1051+
end
1052+
result = find_message(RubyLsp::Result, id: 2)
1053+
assert(result.response[:success])
1054+
assert_path_exists(File.join(dir, ".ruby-lsp", "bundle_is_composed"))
1055+
end
1056+
end
1057+
end
1058+
10361059
private
10371060

10381061
def with_uninstalled_rubocop(&block)

test/setup_bundler_test.rb

+18
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,24 @@ def test_update_does_not_fail_if_gems_are_uninstalled
858858
end
859859
end
860860

861+
def test_only_returns_environment_if_bundle_was_composed_ahead_of_time
862+
Dir.mktmpdir do |dir|
863+
Dir.chdir(dir) do
864+
FileUtils.mkdir(".ruby-lsp")
865+
FileUtils.touch(File.join(".ruby-lsp", "bundle_is_composed"))
866+
867+
require "bundler/cli/update"
868+
require "bundler/cli/install"
869+
Bundler::CLI::Update.expects(:new).never
870+
Bundler::CLI::Install.expects(:new).never
871+
872+
assert_output("", "Ruby LSP> Composed bundle was set up ahead of time. Skipping...\n") do
873+
refute_empty(RubyLsp::SetupBundler.new(dir, launcher: true).setup!)
874+
end
875+
end
876+
end
877+
end
878+
861879
private
862880

863881
def with_default_external_encoding(encoding, &block)

vscode/src/workspace.ts

+29-4
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ export class Workspace implements WorkspaceInterface {
205205
return this.start();
206206
}
207207

208+
let canRestart = false;
209+
208210
switch (this.lspClient.state) {
209211
// If the server is still starting, then it may not be ready to handle a shutdown request yet. Trying to send
210212
// one could lead to a hanging process. Instead we set a flag and only restart once the server finished booting
@@ -214,10 +216,18 @@ export class Workspace implements WorkspaceInterface {
214216
break;
215217
// If the server is running, we want to stop it, dispose of the client and start a new one
216218
case State.Running:
217-
await this.stop();
218-
await this.lspClient.dispose();
219-
this.lspClient = undefined;
220-
await this.start();
219+
// If the server doesn't support checking the validity of the composed bundle or if composing the bundle was
220+
// successful, then we can restart
221+
canRestart =
222+
!this.lspClient.initializeResult?.capabilities.experimental
223+
.compose_bundle || (await this.composingBundleSucceeds());
224+
225+
if (canRestart) {
226+
await this.stop();
227+
await this.lspClient.dispose();
228+
this.lspClient = undefined;
229+
await this.start();
230+
}
221231
break;
222232
// If the server is already stopped, then we need to dispose it and start a new one
223233
case State.Stopped:
@@ -441,4 +451,19 @@ export class Workspace implements WorkspaceInterface {
441451
hash.update(fileContents.toString());
442452
return hash.digest("hex");
443453
}
454+
455+
private async composingBundleSucceeds(): Promise<boolean> {
456+
if (!this.lspClient) {
457+
return false;
458+
}
459+
460+
try {
461+
const response: { success: boolean } = await this.lspClient.sendRequest(
462+
"rubyLsp/composeBundle",
463+
);
464+
return response.success;
465+
} catch (error: any) {
466+
return false;
467+
}
468+
}
444469
}

0 commit comments

Comments
 (0)