diff --git a/.gitignore b/.gitignore index 6bb6a68..267a6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /spec/reports/ /tmp/ /vendor/bundle +/vendor/speedscope diff --git a/Gemfile b/Gemfile index ec1b8a4..97879fc 100644 --- a/Gemfile +++ b/Gemfile @@ -7,5 +7,7 @@ gemspec gem "activejob" gem "rake", "~> 13.0" +gem "rspec" +gem "rubyzip" gem "sidekiq" gem "standard" diff --git a/Gemfile.lock b/Gemfile.lock index da0cabd..02921fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,6 +91,7 @@ GEM logger (>= 1.7.0) rack (>= 3.2.0) redis-client (>= 0.26.0) + rubyzip (3.2.2) stackprof (0.2.28) standard (1.35.1) language_server-protocol (~> 3.17.0.2) @@ -116,6 +117,7 @@ DEPENDENCIES activejob rake (~> 13.0) rspec + rubyzip sidekiq singed! standard diff --git a/Rakefile b/Rakefile index cd510a0..638ee1b 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,55 @@ # frozen_string_literal: true require "bundler/gem_tasks" +require "open-uri" +require "fileutils" +require "tmpdir" +require "zip" +require_relative "lib/singed/speedscope" + +namespace :speedscope do + destination_dir = File.expand_path("vendor/speedscope", __dir__) + + desc "Download and unpack speedscope into vendor/speedscope" + task vendor: destination_dir + + directory destination_dir do + version = Singed::Speedscope::VERSION + url = "https://github.com/jlfwong/speedscope/releases/download/v#{version}/speedscope-#{version}.zip" + + unzip_dir = File.expand_path("..", destination_dir) # speedscope dir is in the archive + FileUtils.mkdir_p(destination_dir) + + tmp_zip = File.join(Dir.tmpdir, "speedscope-#{version}.zip") + + puts "Downloading speedscope from #{url}" + URI.parse(url).open do |remote| + File.open(tmp_zip, "wb") do |file| + IO.copy_stream(remote, file) + end + end + + puts "Vendoring speedscope into #{unzip_dir}" + Zip::File.open(tmp_zip) do |zip_file| + zip_file.each do |entry| + destination = File.join(unzip_dir, entry.name) + if entry.directory? + FileUtils.mkdir_p(destination) + else + FileUtils.mkdir_p(File.dirname(destination)) + entry.extract(destination_directory: unzip_dir) + end + end + end + end + + desc "Remove the unpacked speedscope directory" + task :clobber do + FileUtils.rm_rf(destination_dir) + end +end + +Rake::Task[:build].enhance ["speedscope:vendor"] +Rake::Task[:clobber].enhance ["speedscope:clobber"] + task default: %i[] diff --git a/lib/singed.rb b/lib/singed.rb index a6bb712..2ac5ba9 100644 --- a/lib/singed.rb +++ b/lib/singed.rb @@ -71,6 +71,7 @@ def profiling? autoload :Flamegraph, "singed/flamegraph" autoload :Report, "singed/report" autoload :RackMiddleware, "singed/rack_middleware" + autoload :Speedscope, "singed/speedscope" end require "singed/kernel_ext" diff --git a/lib/singed/flamegraph.rb b/lib/singed/flamegraph.rb index dec8f6e..d5d78ec 100644 --- a/lib/singed/flamegraph.rb +++ b/lib/singed/flamegraph.rb @@ -62,11 +62,11 @@ def save end def open - system open_command + Singed::Speedscope.open(@filename) end def open_command - @open_command ||= "npx speedscope #{@filename}" + Singed::Speedscope.open_command(@filename) end def self.generate_filename(label: nil, time: Time.now) # rubocop:disable Rails/TimeZone diff --git a/lib/singed/kernel_ext.rb b/lib/singed/kernel_ext.rb index 16cb266..c566b92 100644 --- a/lib/singed/kernel_ext.rb +++ b/lib/singed/kernel_ext.rb @@ -8,7 +8,6 @@ def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, io: $s bright_red = "\e[91m" none = "\e[0m" if open - # use npx, so we don't have to add it as a dependency io.puts "🔥📈 #{bright_red}Captured flamegraph, opening with#{none}: #{fg.open_command}" fg.open else diff --git a/lib/singed/speedscope.rb b/lib/singed/speedscope.rb new file mode 100644 index 0000000..d168d9b --- /dev/null +++ b/lib/singed/speedscope.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rbconfig" +require "tmpdir" + +module Singed + module Speedscope + # Take latest version from https://github.com/jlfwong/speedscope/releases + # that have ZIP archive with self-contained version published + VERSION = "1.24.0" + + class << self + def bundled_index_html + File.join(File.expand_path("../..", __dir__), "vendor", "speedscope", "index.html") + end + + def open_command(profile_path) + if File.exist?(bundled_index_html) + "#{os_open_command} file://#{bundled_index_html}#localProfilePath=#{profile_path}" + else + "npx speedscope #{profile_path}" + end + end + + def open(profile_path) + profile_path = profile_path.to_s + + if File.exist?(bundled_index_html) + open_with_bundled_speedscope(profile_path) + else + open_with_npx(profile_path) + end + end + + private + + def open_with_npx(profile_path) + system("npx", "speedscope", profile_path) + end + + # Based on speedscope CLI code (MIT license) + # See https://github.com/jlfwong/speedscope/blob/3613918de0dd55a263d0d04f85b0c8c2039c7bee/bin/cli.mjs + def open_with_bundled_speedscope(profile_path) + source_buffer = File.binread(profile_path) + filename = File.basename(profile_path) + + source_base64 = [source_buffer].pack("m0") + js_source = "speedscope.loadFileFromBase64(#{filename.inspect}, #{source_base64.inspect})" + + file_prefix = "speedscope-#{Time.now.to_i}-#{Process.pid}" + js_path = File.join(Dir.tmpdir, "#{file_prefix}.js") + File.write(js_path, js_source) + + url_to_open = "file://#{File.expand_path(bundled_index_html)}#localProfilePath=#{js_path}" + + # See https://github.com/jlfwong/speedscope/blob/3613918de0dd55a263d0d04f85b0c8c2039c7bee/bin/cli.mjs#L96-L105 + host_os = RbConfig::CONFIG["host_os"] + if host_os =~ /mswin|mingw|cygwin/ || host_os =~ /darwin/ + html_path = File.join(Dir.tmpdir, "#{file_prefix}.html") + File.write(html_path, "") + url_to_open = "file://#{html_path}" + end + + system os_open_command, url_to_open + end + + def os_open_command + case host_os = RbConfig::CONFIG["host_os"] + when /mswin|mingw|cygwin/ + "start" + when /darwin/ + "open" + when /linux|bsd/ + "xdg-open" + else + raise "Unsupported OS to open browser: #{host_os}" + end + end + end + end +end diff --git a/singed.gemspec b/singed.gemspec index 56936ea..04f9c98 100644 --- a/singed.gemspec +++ b/singed.gemspec @@ -16,13 +16,10 @@ Gem::Specification.new do |spec| "homepage_uri" => "https://github.com/rubyatscale/singed" } - spec.files = Dir["README.md", "*.gemspec", "lib/**/*", "exe/**/*"] + spec.files = Dir["README.md", "*.gemspec", "lib/**/*", "exe/**/*", "vendor/speedscope/**/*"] spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_dependency "stackprof", ">= 0.2.13" - - spec.add_development_dependency "rake", "~> 13.0" - spec.add_development_dependency "rspec" end diff --git a/spec/singed/speedscope_spec.rb b/spec/singed/speedscope_spec.rb new file mode 100644 index 0000000..ee292a8 --- /dev/null +++ b/spec/singed/speedscope_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "tempfile" + +RSpec.describe Singed::Speedscope do + describe ".open" do + let(:profile_path) do + Tempfile.new(["profile", ".json"]).tap do |file| + file.write("{}") + file.flush + end.path + end + + context "when bundled speedscope exists" do + before do + allow(File).to receive(:exist?).with(described_class.bundled_index_html).and_return(true) + end + + it "opens with bundled speedscope" do + allow(described_class).to receive(:system).and_return(true) + + described_class.open(profile_path) + + expect(described_class).to have_received(:system).with(described_class.send(:os_open_command), %r{\Afile://}) + end + end + + context "when bundled speedscope does not exist" do + before do + allow(File).to receive(:exist?).with(described_class.bundled_index_html).and_return(false) + end + + it "opens with npx speedscope" do + allow(described_class).to receive(:system).and_return(true) + + described_class.open(profile_path) + + expect(described_class).to have_received(:system).with("npx", "speedscope", profile_path) + end + end + end + + describe ".os_open_command" do + it "returns a command and does not raise" do + expect { described_class.send(:os_open_command) }.not_to raise_error + expect(described_class.send(:os_open_command)).to match(/\A(start|open|xdg-open)\z/) + end + + context "when host_os is stubbed" do + subject { described_class.send(:os_open_command) } + + before do + allow(RbConfig::CONFIG).to receive(:[]).with("host_os").and_return(stubbed_os) + end + + context "on Windows" do + let(:stubbed_os) { "mingw32" } + + it { is_expected.to eq("start") } + end + + context "on MacOS" do + let(:stubbed_os) { "darwin22.0" } + + it { is_expected.to eq("open") } + end + + context "on Linux" do + let(:stubbed_os) { "linux-gnu" } + + it { is_expected.to eq("xdg-open") } + end + + context "on unsupported OS" do + let(:stubbed_os) { "unknown-os" } + + it "raises error" do + expect { subject }.to raise_error(RuntimeError, /unknown-os/) + end + end + end + end +end