diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 788cfddc..f4818dc9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: [2.7, "3.0", 3.1, 3.2, 3.3] + ruby: [2.7, "3.0", 3.1, 3.2, 3.3, "jruby-9.4"] runs-on: ubuntu-latest env: FERRUM_PROCESS_TIMEOUT: 25 diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 9626ad1d..6613c287 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -9,6 +9,7 @@ require "ferrum/browser/xvfb" require "ferrum/browser/options" require "ferrum/browser/process" +require "ferrum/browser/jruby_process" require "ferrum/browser/binary" require "ferrum/browser/version_info" @@ -253,7 +254,8 @@ def headless_new? def start Utils::ElapsedTime.start - @process = Process.new(options) + process_class = Utils::Platform.jruby? ? JrubyProcess : Process + @process = process_class.new(options) begin @process.start diff --git a/lib/ferrum/browser/jruby_process.rb b/lib/ferrum/browser/jruby_process.rb new file mode 100644 index 00000000..27103373 --- /dev/null +++ b/lib/ferrum/browser/jruby_process.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "ferrum/browser/process" + +module Ferrum + class Browser + class JrubyProcess < Process + def start + # Don't do anything as browser is already running as external process. + return if ws_url + + begin + process_builder_args = @command.to_a + # Sometimes subprocesses are launched with the wrong architecture on Apple Silicon. + if ENV_JAVA["os.name"] == "Mac OS X" && ENV_JAVA["os.arch"] == "aarch64" + process_builder_args.unshift("/usr/bin/arch", "-arm64") + end + process_builder = java.lang.ProcessBuilder.new(*process_builder_args) + # unless user directory is on a Windows UNC path + process_builder.directory(java.io.File.new(@user_data_dir)) unless @user_data_dir =~ %r{\A//} + process_builder.redirectErrorStream(true) + + if @command.xvfb? + @xvfb = Xvfb.start(@command.options) + ObjectSpace.define_finalizer(self, self.class.process_killer(@xvfb.pid)) + process_builder.environment.merge! Hash(@xvfb&.to_env) + end + + @java_process = process_builder.start + + # The process output is switched to a buffered reader and parsed to get the WebSocket URL. + input_reader = java.io.BufferedReader.new(java.io.InputStreamReader.new(java_process.getInputStream)) + parse_ws_url(input_reader, @process_timeout) + parse_json_version(ws_url) + end + end + + attr_reader :java_process + + def stop + destroy_java_process + + remove_user_data_dir if @user_data_dir + ObjectSpace.undefine_finalizer(self) + end + + private + + def parse_ws_url(read_io, timeout) + output = "" + start = Utils::ElapsedTime.monotonic_time + max_time = start + timeout + regexp = %r{DevTools listening on (ws://.*[a-zA-Z0-9-]{36})} + while Utils::ElapsedTime.monotonic_time < max_time + # The buffered reader is used to read the process output. + if output.match(regexp) + self.ws_url = output.match(regexp)[1].strip + break + elsif (rl = read_io.read_line) + output += rl + end + end + + return if ws_url + + @logger&.puts(output) + raise ProcessTimeoutError.new(timeout, output) + end + + def destroy_java_process + return unless java_process + + java_process.destroy + retry_times = 6 + while java_process.isAlive && retry_times.positive? + sleep 1 + retry_times -= 1 + end + if java_process.isAlive + @logger&.puts("Ferrum::Browser::JrubyProcess is still alive, killing it forcibly") + java_process.destroyForcibly + else + @logger&.puts("Ferrum::Browser::JrubyProcess is stopped") + end + @java_process = nil + end + end + end +end diff --git a/lib/ferrum/browser/process.rb b/lib/ferrum/browser/process.rb index 7ca321ef..9940d39c 100644 --- a/lib/ferrum/browser/process.rb +++ b/lib/ferrum/browser/process.rb @@ -180,7 +180,7 @@ def close_io(*ios) ios.each do |io| io.close unless io.closed? rescue IOError - raise unless RUBY_ENGINE == "jruby" + raise unless Utils::Platform.jruby? end end diff --git a/lib/ferrum/client.rb b/lib/ferrum/client.rb index de0732f5..676a8b0c 100644 --- a/lib/ferrum/client.rb +++ b/lib/ferrum/client.rb @@ -62,7 +62,7 @@ class Client attr_reader :ws_url, :options, :subscriber def initialize(ws_url, options) - @command_id = 0 + @command_id = Concurrent::AtomicFixnum.new(0) @ws_url = ws_url @options = options @pendings = Concurrent::Hash.new @@ -131,7 +131,7 @@ def build_message(method, params) private def start - @thread = Utils::Thread.spawn do + @thread = Utils::Thread.spawn(abort_on_exception: !Utils::Platform.jruby?) do loop do message = @ws.messages.pop break unless message @@ -141,12 +141,14 @@ def start else @pendings[message["id"]]&.set(message) end + rescue StandardError => e + @logger&.puts("Ferrum::Client thread raised an exception #{e.class.name}: #{e.message}") end end end def next_command_id - @command_id += 1 + @command_id.increment end def raise_browser_error(error) diff --git a/lib/ferrum/client/web_socket.rb b/lib/ferrum/client/web_socket.rb index e88d73bc..25f27ca1 100644 --- a/lib/ferrum/client/web_socket.rb +++ b/lib/ferrum/client/web_socket.rb @@ -93,14 +93,14 @@ def close private def start - @thread = Utils::Thread.spawn do + @thread = Utils::Thread.spawn(abort_on_exception: !Utils::Platform.jruby?) do loop do data = @sock.readpartial(512) break unless data @driver.parse(data) end - rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException + rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF, IOError # rubocop:disable Lint/ShadowedException @messages.close end end diff --git a/lib/ferrum/utils/platform.rb b/lib/ferrum/utils/platform.rb index a38b0622..1530c689 100644 --- a/lib/ferrum/utils/platform.rb +++ b/lib/ferrum/utils/platform.rb @@ -27,6 +27,10 @@ def mac_arm? def mri? defined?(RUBY_ENGINE) && RUBY_ENGINE == "ruby" end + + def jruby? + defined?(JRUBY_VERSION) + end end end end diff --git a/spec/browser_spec.rb b/spec/browser_spec.rb index 0e0c944a..527adc79 100644 --- a/spec/browser_spec.rb +++ b/spec/browser_spec.rb @@ -364,14 +364,28 @@ browser&.quit end - it "supports stopping the session", skip: Ferrum::Utils::Platform.windows? do - browser = Ferrum::Browser.new - pid = browser.process.pid + context "for MRI Ruby", skip: Ferrum::Utils::Platform.windows? || Ferrum::Utils::Platform.jruby? do + it "supports stopping the session" do + browser = Ferrum::Browser.new + pid = browser.process.pid + + expect(Process.kill(0, pid)).to eq(1) + browser.quit - expect(Process.kill(0, pid)).to eq(1) - browser.quit + expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH) + end + end - expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH) + context "for JRuby", skip: Ferrum::Utils::Platform.windows? || Ferrum::Utils::Platform.mri? do + it "supports stopping the session" do + browser = Ferrum::Browser.new + pid = browser.process.java_process.pid + + expect(Process.kill(0, pid)).to eq(1) + browser.quit + + expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH) + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9f94527b..c4a83d16 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -47,6 +47,8 @@ config.after(:all) do @browser.quit + rescue IOError => e + puts "#{e.class}: #{e.message}" end config.before(:each) do diff --git a/spec/unit/process_spec.rb b/spec/unit/process_spec.rb index 5ad6c435..3ca91e8e 100644 --- a/spec/unit/process_spec.rb +++ b/spec/unit/process_spec.rb @@ -3,7 +3,7 @@ describe Ferrum::Browser::Process do subject { Ferrum::Browser.new(port: 6000, host: "127.0.0.1") } - unless Ferrum::Utils::Platform.windows? + unless Ferrum::Utils::Platform.windows? || Ferrum::Utils::Platform.jruby? it "forcibly kills the child if it does not respond to SIGTERM" do allow(Process).to receive(:spawn).and_return(5678) allow(Process).to receive(:wait).and_return(nil)