From 4c523e3d9d0298ba0cb52cf49ed459b09ec4b177 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Sat, 19 Oct 2024 11:16:03 -0400 Subject: [PATCH] Add launcher executable --- exe/ruby-lsp | 19 +++++++- exe/ruby-lsp-launcher | 94 ++++++++++++++++++++++++++++++++++++++++ lib/ruby_lsp/internal.rb | 1 + ruby-lsp.gemspec | 2 +- 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100755 exe/ruby-lsp-launcher diff --git a/exe/ruby-lsp b/exe/ruby-lsp index b5c7bbb59..3658e1ef1 100755 --- a/exe/ruby-lsp +++ b/exe/ruby-lsp @@ -33,6 +33,10 @@ parser = OptionParser.new do |opts| options[:doctor] = true end + opts.on("--use-launcher", "Use launcher mechanism to handle missing dependencies gracefully") do + options[:launcher] = true + end + opts.on("-h", "--help", "Print this help") do puts opts.help puts @@ -54,6 +58,17 @@ end # using `BUNDLE_GEMFILE=.ruby-lsp/Gemfile bundle exec ruby-lsp` so that we have access to the gems that are a part of # the application's bundle if ENV["BUNDLE_GEMFILE"].nil? + # Substitute the current process by the launcher. Rubygems activates all dependencies of a gem's executable eagerly, + # but we can't have that happen because we want to invoke Bundler.setup ourselves with the composed bundle and avoid + # duplicate spec activation errors. Replacing the process with the launcher executable will clear the activated specs, + # which gives us the opportunity to control which specs are activated and enter degraded mode if any gems failed to + # install rather than failing to boot the server completely + if options[:launcher] + command = +File.expand_path("ruby-lsp-launcher", __dir__) + command << " --debug" if options[:debug] + exit exec(command) + end + require_relative "../lib/ruby_lsp/setup_bundler" begin @@ -72,9 +87,9 @@ if ENV["BUNDLE_GEMFILE"].nil? exit exec(env, "#{base_bundle} exec ruby-lsp #{original_args.join(" ")}") end -require "ruby_lsp/load_sorbet" - $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + +require "ruby_lsp/load_sorbet" require "ruby_lsp/internal" T::Utils.run_all_sig_blocks diff --git a/exe/ruby-lsp-launcher b/exe/ruby-lsp-launcher new file mode 100755 index 000000000..55a93a208 --- /dev/null +++ b/exe/ruby-lsp-launcher @@ -0,0 +1,94 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# !!!!!!! +# No gems can be required in this file until we invoke bundler setup except inside the forked process that sets up the +# composed bundle +# !!!!!!! + +setup_error = nil + +# Read the initialize request before even starting the server. We need to do this to figure out the workspace URI. +# Editors are not required to spawn the language server process on the same directory as the workspace URI, so we need +# to ensure that we're setting up the bundle in the right place +$stdin.binmode +headers = $stdin.gets("\r\n\r\n") +content_length = headers[/Content-Length: (\d+)/i, 1].to_i +raw_initialize = $stdin.read(content_length) + +bundle_gemfile_file = File.join(".ruby-lsp", "bundle_gemfile") + +# Composed 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` +pid = fork do + require_relative "../lib/ruby_lsp/setup_bundler" + require "json" + require "uri" + require "core_ext/uri" + + initialize_request = JSON.parse(raw_initialize, symbolize_names: true) + workspace_uri = initialize_request.dig(:params, :workspaceFolders, 0, :uri) + workspace_path = workspace_uri && URI(workspace_uri).to_standardized_path + workspace_path ||= Dir.pwd + + env = RubyLsp::SetupBundler.new(workspace_path).setup! + File.write(bundle_gemfile_file, env["BUNDLE_GEMFILE"]) +rescue RubyLsp::SetupBundler::BundleNotLocked + warn("Project contains a Gemfile, but no Gemfile.lock. Run `bundle install` to lock gems and restart the server") +end + +# Wait until the composed Bundle is finished +Process.wait(pid) + +begin + require "bundler" + Bundler.ui.level = :silent + + # The composed bundle logic informs the main process about which Gemfile to setup Bundler with + if File.exist?(bundle_gemfile_file) + ENV["BUNDLE_GEMFILE"] = File.read(bundle_gemfile_file) + Bundler.setup + end +rescue StandardError => e + # If installing gems failed for any reason, we don't want to exit the process prematurely. We can still provide most + # features in a degraded mode. We simply save the error so that we can report to the user that certain gems might be + # missing, but we respect the LSP life cycle + setup_error = e + + # If we failed to setup the bundle, the minimum we need is to have our own dependencies activated so that we can + # require the gems the LSP depends on. If even that fails, then there's no way we can continue to run the language + # server + Gem::Specification.find_by_name("ruby-lsp").activate +end + +# Now that the bundle is set up, we can begin actually launching the server + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + +require "ruby_lsp/load_sorbet" +require "ruby_lsp/internal" + +T::Utils.run_all_sig_blocks + +if ARGV.include?("--debug") + if ["x64-mingw-ucrt", "x64-mingw32"].include?(RUBY_PLATFORM) + $stderr.puts "Debugging is not supported on Windows" + else + begin + ENV.delete("RUBY_DEBUG_IRB_CONSOLE") + require "debug/open_nonstop" + rescue LoadError + $stderr.puts("You need to install the debug gem to use the --debug flag") + end + end +end + +# Ensure all output goes out stderr by default to allow puts/p/pp to work without specifying output device. +$> = $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 diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index bcf595723..37d90f751 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -12,6 +12,7 @@ require "bundler" Bundler.ui.level = :silent +require "json" require "uri" require "cgi" require "set" diff --git a/ruby-lsp.gemspec b/ruby-lsp.gemspec index 45f0d9662..41bca9ace 100644 --- a/ruby-lsp.gemspec +++ b/ruby-lsp.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |s| s.files = Dir.glob("lib/**/*.rb") + ["README.md", "VERSION", "LICENSE.txt"] + Dir.glob("static_docs/**/*.md") s.bindir = "exe" - s.executables = ["ruby-lsp", "ruby-lsp-check"] + s.executables = ["ruby-lsp", "ruby-lsp-check", "ruby-lsp-launcher"] s.require_paths = ["lib"] # Dependencies must be kept in sync with the checks in the extension side on workspace.ts