Skip to content

Commit 12d6d3b

Browse files
committed
Check if bundle is valid before restarting
1 parent 7fb9adf commit 12d6d3b

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: {
@@ -1279,5 +1282,30 @@ def window_show_message_request(message)
12791282

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

998+
def test_compose_bundle_creates_file_to_skip_next_compose
999+
Dir.mktmpdir do |dir|
1000+
Dir.chdir(dir) do
1001+
@server.process_message({
1002+
id: 1,
1003+
method: "initialize",
1004+
params: {
1005+
initializationOptions: {},
1006+
capabilities: { general: { positionEncodings: ["utf-8"] } },
1007+
workspaceFolders: [{ uri: URI::Generic.from_path(path: dir).to_s }],
1008+
},
1009+
})
1010+
1011+
capture_subprocess_io do
1012+
@server.process_message({ id: 2, method: "rubyLsp/composeBundle" })
1013+
end
1014+
result = find_message(RubyLsp::Result, id: 2)
1015+
assert(result.response[:success])
1016+
assert_path_exists(File.join(dir, ".ruby-lsp", "bundle_is_composed"))
1017+
end
1018+
end
1019+
end
1020+
9981021
private
9991022

10001023
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)