Skip to content

Commit

Permalink
Add launcher executable
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Oct 22, 2024
1 parent 3d2fb25 commit 64380b8
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 3 deletions.
19 changes: 17 additions & 2 deletions exe/ruby-lsp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
105 changes: 105 additions & 0 deletions exe/ruby-lsp-launcher
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/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")
locked_bundler_version_file = File.join(".ruby-lsp", "locked_bundler_version")

# 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"])
File.write(locked_bundler_version_file, env["BUNDLER_VERSION"]) if env["BUNDLER_VERSION"]
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
# We can't require `bundler/setup` because that file prematurely exits the process if setup fails. However, we can't
# simply require bundler either because the version required might conflict with the one locked in the composed
# bundle. We need the composed bundle sub-process to inform us of the locked Bundler version, so that we can then
# activate the right spec and require the exact Bundler version required by the app
if File.exist?(locked_bundler_version_file)
locked_bundler_version = File.read(locked_bundler_version_file)
Gem::Specification.find_by_name("bundler", locked_bundler_version).activate
end

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
1 change: 1 addition & 0 deletions lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require "bundler"
Bundler.ui.level = :silent

require "json"
require "uri"
require "cgi"
require "set"
Expand Down
2 changes: 1 addition & 1 deletion ruby-lsp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 64380b8

Please sign in to comment.