Skip to content

Commit

Permalink
Use Bundler CLI directly and send errors to telemetry
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Oct 24, 2024
1 parent 664b8cc commit ba09bd2
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 18 deletions.
33 changes: 26 additions & 7 deletions exe/ruby-lsp-launcher
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ locked_bundler_version_file = File.join(".ruby-lsp", "locked_bundler_version")
# Compose the Ruby LSP bundle in a forked process so that we can require gems without polluting the main process
# `$LOAD_PATH` and `Gem.loaded_specs`. Windows doesn't support forking, so we need a separate path to support it
pid = if Gem.win_platform?
spawn(Gem.ruby, File.expand_path("../lib/ruby_lsp/scripts/compose_bundle_windows.rb", __dir__), raw_initialize)
Process.spawn(
Gem.ruby,
File.expand_path("../lib/ruby_lsp/scripts/compose_bundle_windows.rb", __dir__),
raw_initialize,
)
else
fork do
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
Expand Down Expand Up @@ -64,10 +68,15 @@ rescue StandardError => e
Gem::Specification.find_by_name("ruby-lsp").activate
end

# Now that the bundle is set up, we can begin actually launching the server
error_path = File.join(".ruby-lsp", "install_error")

$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
install_error = if File.exist?(error_path)
Marshal.load(File.read(error_path))
end

# Now that the bundle is set up, we can begin actually launching the server. Note that `Bundler.setup` will have already
# configured the load path using the version of the Ruby LSP present in the composed bundle. Do not push any Ruby LSP
# paths into the load path manually or we may end up requiring the wrong version of the gem
require "ruby_lsp/load_sorbet"
require "ruby_lsp/internal"

Expand All @@ -91,7 +100,17 @@ $> = $stderr

initialize_request = JSON.parse(raw_initialize, symbolize_names: true) if raw_initialize

RubyLsp::Server.new(
setup_error: setup_error,
initialize_request: initialize_request,
).start
begin
RubyLsp::Server.new(
install_error: install_error,
setup_error: setup_error,
initialize_request: initialize_request,
).start
rescue ArgumentError
# If the launcher is booting an outdated version of the server, then the initializer doesn't accept a keyword splat
# and we already read the initialize request from the stdin pipe. In this case, we need to process the initialize
# request manually and then start the main loop
server = RubyLsp::Server.new
server.process_message(initialize_request)
server.start
end
1 change: 1 addition & 0 deletions lib/ruby_lsp/base_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class BaseServer
def initialize(**options)
@test_mode = T.let(options[:test_mode], T.nilable(T::Boolean))
@setup_error = T.let(options[:setup_error], T.nilable(StandardError))
@install_error = T.let(options[:install_error], T.nilable(StandardError))
@writer = T.let(Transport::Stdio::Writer.new, Transport::Stdio::Writer)
@reader = T.let(Transport::Stdio::Reader.new, Transport::Stdio::Reader)
@incoming_queue = T.let(Thread::Queue.new, Thread::Queue)
Expand Down
22 changes: 14 additions & 8 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -292,15 +292,21 @@ def run_initialize(message)
global_state_notifications.each { |notification| send_message(notification) }

if @setup_error
message = <<~MESSAGE
An error occurred while setting up Bundler. This may be due to a failure when installing dependencies.
The Ruby LSP will continue to run, but features related to the missing dependencies will be limited.
Error:
#{@setup_error.full_message}
MESSAGE
send_message(Notification.telemetry(
type: "error",
errorMessage: @setup_error.message,
errorClass: @setup_error.class,
stack: @setup_error.backtrace&.join("\n"),
))
end

send_message(Notification.window_log_message(message, type: Constant::MessageType::ERROR))
if @install_error
send_message(Notification.telemetry(
type: "error",
errorMessage: @install_error.message,
errorClass: @install_error.class,
stack: @install_error.backtrace&.join("\n"),
))
end
end

Expand Down
53 changes: 53 additions & 0 deletions lib/ruby_lsp/setup_bundler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

require "sorbet-runtime"
require "bundler"
require "bundler/cli"
require "bundler/cli/install"
require "bundler/cli/update"
require "fileutils"
require "pathname"
require "digest"
Expand Down Expand Up @@ -48,6 +51,7 @@ def initialize(project_path, **options)
@custom_lockfile = T.let(@custom_dir + (@lockfile&.basename || "Gemfile.lock"), Pathname)
@lockfile_hash_path = T.let(@custom_dir + "main_lockfile_hash", Pathname)
@last_updated_path = T.let(@custom_dir + "last_updated", Pathname)
@error_path = T.let(@custom_dir + "install_error", Pathname)

dependencies, bundler_version = load_dependencies
@dependencies = T.let(dependencies, T::Hash[String, T.untyped])
Expand Down Expand Up @@ -187,6 +191,55 @@ def run_bundle_install(bundle_gemfile = @gemfile)
env["BUNDLE_PATH"] = File.expand_path(env["BUNDLE_PATH"], @project_path)
end

return run_bundle_install_through_command(env) unless @launcher

# This same check happens conditionally when running through the command. For invoking the CLI directly, it's
# important that we ensure the Bundler version is set to avoid restarts
if @bundler_version
env["BUNDLER_VERSION"] = @bundler_version.to_s
install_bundler_if_needed
end

begin
run_bundle_install_directly(env)
# If no error occurred, then clear previous errors
@error_path.delete if @error_path.exist?
$stderr.puts("Ruby LSP> Composed bundle setup complete")
rescue => e
# Write the error object to a file so that we can read it from the parent process
@error_path.write(Marshal.dump(e))
end

env
end

sig { params(env: T::Hash[String, String]).returns(T::Hash[String, String]) }
def run_bundle_install_directly(env)
T.unsafe(ENV).merge!(env)

unless should_bundle_update?
RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)
Bundler::CLI::Install.new({}).run

return env
end

# If any of `ruby-lsp`, `ruby-lsp-rails` or `debug` are not in the Gemfile, try to update them to the latest
# version
gems = []
gems << "ruby-lsp" unless @dependencies["ruby-lsp"]
gems << "debug" unless @dependencies["debug"]
gems << "ruby-lsp-rails" if @rails_app && !@dependencies["ruby-lsp-rails"]

RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)
Bundler::CLI::Update.new({}, gems).run

@last_updated_path.write(Time.now.iso8601)
env
end

sig { params(env: T::Hash[String, String]).returns(T::Hash[String, String]) }
def run_bundle_install_through_command(env)
base_bundle = base_bundle_command(env)

# If `ruby-lsp` and `debug` (and potentially `ruby-lsp-rails`) are already in the Gemfile, then we shouldn't try
Expand Down
8 changes: 8 additions & 0 deletions lib/ruby_lsp/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ def window_log_message(message, type: Constant::MessageType::LOG)
params: Interface::LogMessageParams.new(type: type, message: message),
)
end

sig { params(data: T::Hash[Symbol, T.untyped]).returns(Notification) }
def telemetry(data)
new(
method: "telemetry/event",
params: data,
)
end
end

extend T::Sig
Expand Down
26 changes: 23 additions & 3 deletions sorbet/rbi/shims/bundler.rbi
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# typed: true

class Bundler::Settings
sig { params(name: String).returns(String) }
def self.key_for(name); end
module Bundler
class Settings
sig { params(name: String).returns(String) }
def self.key_for(name); end
end

module CLI
class Install
sig { params(options: T::Hash[String, T.untyped]).void }
def initialize(options); end

sig { void }
def run; end
end

class Update
sig { params(options: T::Hash[String, T.untyped], gems: T::Array[String]).void }
def initialize(options, gems); end

sig { void }
def run; end
end
end
end
53 changes: 53 additions & 0 deletions test/setup_bundler_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,59 @@ def test_sets_bundler_version_to_avoid_reloads
end
end

def test_invoke_cli_calls_bundler_directly_for_install
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
File.write(File.join(dir, "gems.rb"), <<~GEMFILE)
source "https://rubygems.org"
gem "irb"
GEMFILE

Bundler.with_unbundled_env do
capture_subprocess_io do
system("bundle install")

mock_object = mock("install")
mock_object.expects(:run)
Bundler::CLI::Install.expects(:new).with({}).returns(mock_object)
RubyLsp::SetupBundler.new(dir, launcher: true).setup!
end
end
end
end
end

def test_invoke_cli_calls_bundler_directly_for_update
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
File.write(File.join(dir, "Gemfile"), <<~GEMFILE)
source "https://rubygems.org"
gem "rdoc"
GEMFILE

capture_subprocess_io do
Bundler.with_unbundled_env do
# Run bundle install to generate the lockfile
system("bundle install")

# Run the script once to generate a custom bundle
run_script(dir)
end
end

capture_subprocess_io do
Bundler.with_unbundled_env do
mock_object = mock("update")
mock_object.expects(:run)
require "bundler/cli/update"
Bundler::CLI::Update.expects(:new).with({}, ["ruby-lsp", "debug"]).returns(mock_object)
RubyLsp::SetupBundler.new(dir, launcher: true).setup!
end
end
end
end
end

private

def with_default_external_encoding(encoding, &block)
Expand Down

0 comments on commit ba09bd2

Please sign in to comment.