ferrum 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7bb51607dd8307f471fa1fcf5214a392485caf1620588798c60dadd6fb41382
4
- data.tar.gz: d4ce789910ecec970a5a33ca3843ca79b148934b75397ec19df58dd81cca49af
3
+ metadata.gz: 4a719b34dd1893e7c0ba2b71e121e1a2b7cc7950b96a1e462ac6a3a9f36c04e0
4
+ data.tar.gz: 4c96a549ff3aa00d76420b43c2f7b4870cacb712d390efe7394ad2a8245f6825
5
5
  SHA512:
6
- metadata.gz: b77f1fb177afcd34a22b4c82f1cb31469cf319edfaf6d67758091cd87d10a2227fdeaa51875979d5cc6e9e357bc0df0ab3ab182920525abcf5f3d81133e34c86
7
- data.tar.gz: 40d23db769aba58e34709d187ec680b10ce630826d6174ddf829fa15d2e7095d99eea0fc7729c75640505a8290ad6caf221c8837d50df2dd4c0671b015cffd75
6
+ metadata.gz: 1a4e9981f2e6334b2862a073eb645b893bbebd42399f0e4b3090d98ea3d69ab0d3104bebe34f86e66b3ec2ea01b827efa28b75c2be55482de6f89a8b082b2113
7
+ data.tar.gz: acab89439a3655e51c3173bc30199bbccb6511567803fa16f066b26fbd6e5934f75492b6ed6383c8c59a27e397e23c781226abd439c551bc6be63c001868bc3a
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ## [Documentation](https://docs.rubycdp.com/docs/ferrum/introduction)
4
4
 
5
5
  <img align="right"
6
- width="320" height="241"
6
+ width="160" height="160"
7
7
  alt="Ferrum logo"
8
8
  src="https://raw.githubusercontent.com/rubycdp/ferrum/main/logo.svg?sanitize=true">
9
9
 
@@ -18,6 +18,8 @@ browsers.
18
18
  * [Cuprite](https://github.com/rubycdp/cuprite) is a pure Ruby driver for [Capybara](https://github.com/teamcapybara/capybara) based on Ferrum.
19
19
  * [Vessel](https://github.com/rubycdp/vessel) high-level web crawling framework based on Ferrum and Mechanize.
20
20
 
21
+ <br clear="right">
22
+
21
23
  ## Install
22
24
 
23
25
  There's no official Chrome or Chromium package for Linux don't install it this
@@ -68,7 +68,7 @@ module Ferrum
68
68
  [paths, exts]
69
69
  end
70
70
 
71
- # rubocop:disable Style/CollectionCompact
71
+ # rubocop:disable-next Style/CollectionCompact
72
72
  def lazy_find(cmds)
73
73
  cmds.lazy.map do |cmd, path, ext|
74
74
  absolute_path = File.absolute_path(cmd)
@@ -81,7 +81,6 @@ module Ferrum
81
81
  cmd
82
82
  end.reject(&:nil?) # .compact isn't defined on Enumerator::Lazy
83
83
  end
84
- # rubocop:enable Style/CollectionCompact
85
84
  end
86
85
  end
87
86
  end
@@ -17,8 +17,6 @@ module Ferrum
17
17
  # https://github.com/cyrus-and/chrome-remote-interface#implementations
18
18
  def self.build(options, user_data_dir)
19
19
  defaults = case options.browser_name
20
- when :firefox
21
- Options::Firefox.options
22
20
  when :chrome, :opera, :edge, nil
23
21
  Options::Chrome.options
24
22
  else
@@ -8,10 +8,9 @@ module Ferrum
8
8
  class Options
9
9
  #
10
10
  # Abstract base for browser-specific default option builders. Subclasses
11
- # (e.g. {Options::Chrome}, {Options::Firefox}) declare their own
12
- # `DEFAULT_OPTIONS` and `PLATFORM_PATH` constants and implement
13
- # {#merge_required}/{#merge_default} to produce the final CLI flags used
14
- # by {Command}.
11
+ # (e.g. {Options::Chrome}) declare their own `DEFAULT_OPTIONS` and
12
+ # `PLATFORM_PATH` constants and implement {#merge_required}/{#merge_default}
13
+ # to produce the final CLI flags used by {Command}.
15
14
  #
16
15
  class Base
17
16
  include Singleton
@@ -71,7 +70,7 @@ module Ferrum
71
70
  #
72
71
  # Merges the CLI flags that are always required to drive the browser
73
72
  # over CDP (e.g. remote debugging port, user data dir) into `flags`.
74
- # Abstract; overridden by {Options::Chrome}/{Options::Firefox}.
73
+ # Abstract; overridden by {Options::Chrome}.
75
74
  #
76
75
  # @param [Hash{String => Object}] flags
77
76
  # The flags accumulated so far.
@@ -92,7 +91,7 @@ module Ferrum
92
91
  #
93
92
  # Merges this browser's default CLI flags (see {#to_h}) into `flags`,
94
93
  # unless `options.ignore_default_browser_options` is set. Abstract;
95
- # overridden by {Options::Chrome}/{Options::Firefox}.
94
+ # overridden by {Options::Chrome}.
96
95
  #
97
96
  # @param [Hash{String => Object}] flags
98
97
  # The flags accumulated so far.
@@ -61,7 +61,6 @@ module Ferrum
61
61
  "metrics-recording-only" => nil,
62
62
  "mute-audio" => nil,
63
63
  "no-crash-upload" => nil,
64
- "no-crashpad" => nil,
65
64
  "no-default-browser-check" => nil,
66
65
  "no-first-run" => nil,
67
66
  "no-startup-window" => nil,
@@ -104,7 +103,6 @@ module Ferrum
104
103
  #
105
104
  def merge_required(flags, options, user_data_dir)
106
105
  flags = flags.merge("remote-debugging-port" => options.port,
107
- "remote-debugging-address" => options.host,
108
106
  "window-size" => options.window_size&.join(","),
109
107
  "user-data-dir" => user_data_dir)
110
108
 
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ferrum/utils/elapsed_time"
5
+ require "ferrum/utils/platform"
6
+
7
+ module Ferrum
8
+ class Browser
9
+ class Process
10
+ #
11
+ # OS-level process termination and user-data-directory removal, kept
12
+ # separate from the rest of {Process} (spawning, CDP handshake, ...).
13
+ # Both the GC finalizer and the explicit {Process#stop} path need to
14
+ # run the exact same kill/cleanup logic on plain pid/path values --
15
+ # never on `self`. A finalizer proc that closes over the object it's
16
+ # attached to creates a reference cycle GC can never collect, so the
17
+ # finalizer would never run; keeping this logic as free functions
18
+ # taking plain arguments sidesteps that entirely.
19
+ #
20
+ module Killer
21
+ module_function
22
+
23
+ KILL_TIMEOUT = 2
24
+ WAIT_KILLED = 0.05
25
+ REAP_TIMEOUT = 0.5
26
+ REMOVE_DIR_RETRIES = 5
27
+ REMOVE_DIR_RETRY_DELAY = 0.1
28
+
29
+ #
30
+ # Kills the process at the given pid (and its process group, if
31
+ # any), escalating from TERM to KILL if it doesn't exit within
32
+ # {KILL_TIMEOUT} seconds.
33
+ #
34
+ # @param [Integer] pid
35
+ #
36
+ # @return [void]
37
+ #
38
+ def kill(pid)
39
+ if Utils::Platform.windows?
40
+ # Process.kill is unreliable on Windows
41
+ ::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL")
42
+ return
43
+ end
44
+
45
+ send_signal(pid, "TERM")
46
+ start = Utils::ElapsedTime.monotonic_time
47
+ leader_exited = false
48
+ loop do
49
+ # The leader (the pid we actually spawned and can #wait on) may
50
+ # exit well before the rest of its process group does -- e.g. a
51
+ # renderer/zygote that ignores TERM. Reaping the leader must not
52
+ # by itself end the loop, or that child is orphaned forever
53
+ # since KILL is only ever sent from the timeout branch below.
54
+ leader_exited ||= !::Process.wait(pid, ::Process::WNOHANG).nil?
55
+ break if leader_exited && !process_group_alive?(pid)
56
+
57
+ sleep(WAIT_KILLED)
58
+ next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT)
59
+
60
+ send_signal(pid, "KILL")
61
+ wait_reaped(pid) unless leader_exited
62
+ break
63
+ end
64
+ rescue Errno::ESRCH, Errno::ECHILD
65
+ # nop
66
+ end
67
+
68
+ #
69
+ # Waits for `pid` to become reapable, giving up after `timeout`.
70
+ #
71
+ # Deliberately a bounded poll rather than a blocking `Process.wait`.
72
+ # {#finalizer} installs {#kill} as an `ObjectSpace` finalizer, so
73
+ # it can run while the interpreter is shutting down, and a blocking
74
+ # wait there never returns.
75
+ #
76
+ # @param [Integer] pid
77
+ # @param [Float] timeout
78
+ #
79
+ # @return [Integer, nil]
80
+ # The reaped pid, or `nil` if it didn't exit in time or wasn't ours.
81
+ #
82
+ def wait_reaped(pid, timeout: REAP_TIMEOUT)
83
+ start = Utils::ElapsedTime.monotonic_time
84
+ loop do
85
+ reaped = ::Process.wait(pid, ::Process::WNOHANG)
86
+ return reaped if reaped
87
+ return nil if Utils::ElapsedTime.timeout?(start, timeout)
88
+
89
+ sleep(WAIT_KILLED)
90
+ end
91
+ rescue Errno::ECHILD, Errno::ESRCH
92
+ nil
93
+ end
94
+
95
+ #
96
+ # Builds the proc registered as a browser's `ObjectSpace` finalizer,
97
+ # cleaning up after a browser nobody quit.
98
+ #
99
+ # Kills every pid first and removes the directory afterwards. That
100
+ # order is the whole point of this being one finalizer instead of
101
+ # two: Ruby runs an object's finalizers in registration order, and
102
+ # removing the user data directory while the browser is still alive
103
+ # means Chrome recreates it on the way out, leaking it for good.
104
+ #
105
+ # @param [Array<Integer>, Integer, nil] pids
106
+ # Process ids to kill, e.g. the browser and Xvfb.
107
+ # @param [String, nil] path
108
+ # User data directory to remove once they are dead.
109
+ #
110
+ # @return [Proc]
111
+ #
112
+ def finalizer(pids, path)
113
+ pids = Array(pids).compact
114
+ proc do
115
+ pids.each { |pid| kill(pid) }
116
+ remove_directory(path) if path
117
+ end
118
+ end
119
+
120
+ #
121
+ # Signals the whole process group Chrome was spawned into (it's
122
+ # spawned with `pgroup: true`), so its child processes (renderer,
123
+ # GPU, zygote, ...) are cleaned up too instead of being left
124
+ # orphaned. Falls back to signaling just the pid directly if
125
+ # there's no such process group to signal (e.g. Xvfb, which isn't
126
+ # spawned with `pgroup: true`) or the group can't be signaled.
127
+ #
128
+ # @param [Integer] pid
129
+ # @param [String] name
130
+ #
131
+ # @return [void]
132
+ #
133
+ def send_signal(pid, name)
134
+ ::Process.kill(name, -pid)
135
+ rescue Errno::EPERM, Errno::ESRCH
136
+ ::Process.kill(name, pid)
137
+ end
138
+
139
+ #
140
+ # Checks whether any process in pid's process group is still alive.
141
+ #
142
+ # @param [Integer] pid
143
+ #
144
+ # @return [Boolean]
145
+ #
146
+ def process_group_alive?(pid)
147
+ ::Process.kill(0, -pid)
148
+ true
149
+ rescue Errno::ESRCH
150
+ false
151
+ rescue Errno::EPERM
152
+ true
153
+ end
154
+
155
+ #
156
+ # Removes the given directory, retrying with exponential backoff on
157
+ # transient errors. Chrome can briefly hold file locks right after
158
+ # being killed, so the directory may not be removable on the first
159
+ # try; retrying avoids leaking temp directories in that case.
160
+ #
161
+ # @param [String] path
162
+ # Directory to remove.
163
+ # @param [Integer] retries
164
+ # Maximum number of removal attempts.
165
+ # @param [Float] delay
166
+ # Base delay, in seconds, before the first retry; doubles on each
167
+ # subsequent attempt.
168
+ #
169
+ # @return [void]
170
+ #
171
+ def remove_directory(path, retries: REMOVE_DIR_RETRIES, delay: REMOVE_DIR_RETRY_DELAY)
172
+ retries.times do |attempt|
173
+ FileUtils.remove_entry(path)
174
+ break
175
+ rescue Errno::ENOENT
176
+ break
177
+ rescue Errno::ENOTEMPTY, Errno::EBUSY, Errno::EACCES, Errno::EPERM => e
178
+ raise e if attempt == retries - 1
179
+
180
+ sleep(delay * (2**attempt))
181
+ end
182
+ rescue StandardError => e
183
+ warn("[Ferrum] Failed to remove user data dir #{path}: #{e.class}: #{e.message}")
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -7,10 +7,11 @@ require "tmpdir"
7
7
  require "forwardable"
8
8
  require "ferrum/browser/options/base"
9
9
  require "ferrum/browser/options/chrome"
10
- require "ferrum/browser/options/firefox"
10
+ require "ferrum/browser/process/killer"
11
11
  require "ferrum/browser/command"
12
12
  require "ferrum/utils/elapsed_time"
13
13
  require "ferrum/utils/platform"
14
+ require "ferrum/utils/thread"
14
15
 
15
16
  module Ferrum
16
17
  class Browser
@@ -21,11 +22,12 @@ module Ferrum
21
22
  # stopping/restarting the process and cleaning up its user data directory.
22
23
  #
23
24
  class Process
24
- KILL_TIMEOUT = 2
25
- WAIT_KILLED = 0.05
26
-
27
25
  extend Forwardable
28
26
 
27
+ HTTP_SCHEMES = { "ws" => "http", "wss" => "https" }.freeze
28
+ WS_SCHEMES = { "http" => "ws", "https" => "wss" }.freeze
29
+ LOCAL_HOSTS = %w[0.0.0.0 :: 127.0.0.1 ::1 localhost].freeze
30
+
29
31
  delegate path: :command
30
32
 
31
33
  #
@@ -40,66 +42,23 @@ module Ferrum
40
42
  new(*args).tap(&:start)
41
43
  end
42
44
 
43
- #
44
- # Builds a finalizer proc that kills the process with the given pid.
45
- #
46
- # @param [Integer] pid
47
- # Process id to kill.
48
- #
49
- # @return [Proc]
50
- #
51
- def self.process_killer(pid)
52
- proc do
53
- if Utils::Platform.windows?
54
- # Process.kill is unreliable on Windows
55
- ::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL")
56
- else
57
- ::Process.kill("USR1", pid)
58
- start = Utils::ElapsedTime.monotonic_time
59
- while ::Process.wait(pid, ::Process::WNOHANG).nil?
60
- sleep(WAIT_KILLED)
61
- next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT)
62
-
63
- ::Process.kill("KILL", pid)
64
- ::Process.wait(pid)
65
- break
66
- end
67
- end
68
- rescue Errno::ESRCH, Errno::ECHILD
69
- # nop
70
- end
71
- end
72
-
73
- #
74
- # Builds a finalizer proc that removes the directory at the given path.
75
- #
76
- # @param [String] path
77
- # Directory to remove.
78
- #
79
- # @return [Proc]
80
- #
81
- def self.directory_remover(path)
82
- proc {
83
- begin
84
- FileUtils.remove_entry(path)
85
- rescue StandardError
86
- Errno::ENOENT
87
- end
88
- }
89
- end
90
-
91
45
  attr_reader :host, :port, :ws_url, :pid, :command,
92
46
  :default_user_agent, :browser_version, :protocol_version,
93
47
  :v8_version, :webkit_version, :xvfb
94
48
 
95
49
  def initialize(options)
96
50
  @pid = @xvfb = @user_data_dir = nil
51
+ @requested_host = options.host
97
52
 
98
53
  if options.ws_url || options.url
99
54
  # `:ws_url` option is higher priority than `:url`, parse versions
100
55
  # and use it as a ws_url, otherwise use what has been parsed.
101
- response = parse_json_version(options.ws_url || options.url)
102
- self.ws_url = options.ws_url || response&.[]("webSocketDebuggerUrl")
56
+ endpoint = options.ws_url || options.url
57
+ response = parse_json_version(endpoint)
58
+ ws_url = options.ws_url || reachable_ws_url(response&.[]("webSocketDebuggerUrl"), endpoint)
59
+ raise NoWebSocketUrlError, endpoint unless ws_url
60
+
61
+ self.ws_url = ws_url
103
62
  return
104
63
  end
105
64
 
@@ -107,10 +66,8 @@ module Ferrum
107
66
  @process_timeout = options.process_timeout
108
67
  @env = Hash(options.env)
109
68
 
110
- tmpdir = Dir.mktmpdir("ferrum_user_data_dir_")
111
- ObjectSpace.define_finalizer(self, self.class.directory_remover(tmpdir))
112
- @user_data_dir = tmpdir
113
- @command = Command.build(options, tmpdir)
69
+ @user_data_dir = Dir.mktmpdir("ferrum_user_data_dir_")
70
+ @command = Command.build(options, @user_data_dir)
114
71
  end
115
72
 
116
73
  #
@@ -128,14 +85,11 @@ module Ferrum
128
85
  process_options[:pgroup] = true unless Utils::Platform.windows?
129
86
  process_options[:out] = process_options[:err] = write_io
130
87
 
131
- if @command.xvfb?
132
- @xvfb = Xvfb.start(@command.options)
133
- ObjectSpace.define_finalizer(self, self.class.process_killer(@xvfb.pid))
134
- end
88
+ @xvfb = Xvfb.start(@command.options) if @command.xvfb?
135
89
 
136
90
  env = Hash(@xvfb&.to_env).merge(@env)
137
91
  @pid = ::Process.spawn(env, *@command.to_a, process_options)
138
- ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
92
+ ObjectSpace.define_finalizer(self, Killer.finalizer([@pid, @xvfb&.pid], @user_data_dir))
139
93
 
140
94
  parse_ws_url(read_io, @process_timeout)
141
95
  parse_json_version(ws_url)
@@ -148,17 +102,26 @@ module Ferrum
148
102
  # Kills the browser process (and Xvfb, if running) and removes the user
149
103
  # data directory.
150
104
  #
151
- # @return [void]
105
+ # @param [Boolean] wait
106
+ # Whether to block until the process is confirmed dead and its user
107
+ # data directory removed (the default), or return immediately and
108
+ # run that cleanup on a background thread instead. Killing a
109
+ # stubborn process group can block for up to {Killer::KILL_TIMEOUT}
110
+ # seconds, plus retries removing its directory, which matters when quitting
111
+ # many browsers in a hot path. `wait: false` is not used by
112
+ # {#restart}, which always waits so the old process is fully gone
113
+ # before the new one starts.
152
114
  #
153
- def stop
154
- if @pid
155
- kill(@pid)
156
- kill(@xvfb.pid) if @xvfb&.pid
157
- @pid = nil
158
- end
115
+ # @return [Thread, nil]
116
+ # The background cleanup thread when `wait: false`; the caller can
117
+ # `#join` it if they need cleanup to have finished, e.g. before
118
+ # process exit or before reusing a fixed port. `nil` when `wait:
119
+ # true`.
120
+ #
121
+ def stop(wait: true)
122
+ return sync_stop if wait
159
123
 
160
- remove_user_data_dir if @user_data_dir
161
- ObjectSpace.undefine_finalizer(self)
124
+ async_stop
162
125
  end
163
126
 
164
127
  #
@@ -189,12 +152,40 @@ module Ferrum
189
152
 
190
153
  private
191
154
 
192
- def kill(pid)
193
- self.class.process_killer(pid).call
155
+ def sync_stop
156
+ if @pid
157
+ Killer.kill(@pid)
158
+ Killer.kill(@xvfb.pid) if @xvfb&.pid
159
+ @pid = nil
160
+ end
161
+
162
+ remove_user_data_dir if @user_data_dir
163
+ ObjectSpace.undefine_finalizer(self)
164
+ nil
165
+ end
166
+
167
+ #
168
+ # Snapshots what needs killing/removing, clears instance state so the
169
+ # object looks stopped right away, and does the actual work on a
170
+ # background thread. The finalizer is left in place as a backup until
171
+ # that thread finishes, in case the process exits before it does.
172
+ #
173
+ def async_stop
174
+ pid = @pid
175
+ xvfb_pid = @xvfb&.pid
176
+ user_data_dir = @user_data_dir
177
+ @pid = @user_data_dir = nil
178
+
179
+ Utils::Thread.spawn do
180
+ Killer.kill(pid) if pid
181
+ Killer.kill(xvfb_pid) if xvfb_pid
182
+ Killer.remove_directory(user_data_dir) if user_data_dir
183
+ ObjectSpace.undefine_finalizer(self)
184
+ end
194
185
  end
195
186
 
196
187
  def remove_user_data_dir
197
- self.class.directory_remover(@user_data_dir).call
188
+ Killer.remove_directory(@user_data_dir)
198
189
  @user_data_dir = nil
199
190
  end
200
191
 
@@ -210,7 +201,7 @@ module Ferrum
210
201
  read_io.wait_readable(max_time - now)
211
202
  else
212
203
  if output.match(regexp)
213
- self.ws_url = output.match(regexp)[1].strip
204
+ self.ws_url = rewrite_host(output.match(regexp)[1].strip)
214
205
  break
215
206
  end
216
207
  end
@@ -228,6 +219,12 @@ module Ferrum
228
219
  @port = @ws_url.port
229
220
  end
230
221
 
222
+ def rewrite_host(url)
223
+ uri = Addressable::URI.parse(url)
224
+ uri.host = @requested_host
225
+ uri.to_s
226
+ end
227
+
231
228
  def close_io(*ios)
232
229
  ios.each do |io|
233
230
  io.close if io && !io.closed?
@@ -237,18 +234,7 @@ module Ferrum
237
234
  end
238
235
 
239
236
  def parse_json_version(url)
240
- url = URI.join(url, "/json/version")
241
-
242
- if %w[wss ws].include?(url.scheme)
243
- url.scheme = case url.scheme
244
- when "ws"
245
- "http"
246
- when "wss"
247
- "https"
248
- end
249
- end
250
-
251
- response = JSON.parse(::Net::HTTP.get(URI(url.to_s)))
237
+ response = JSON.parse(::Net::HTTP.get(URI(json_version_url(url).to_s)))
252
238
 
253
239
  @v8_version = response["V8-Version"]
254
240
  @browser_version = response["Browser"]
@@ -260,6 +246,25 @@ module Ferrum
260
246
  rescue JSON::ParserError
261
247
  # nop
262
248
  end
249
+
250
+ def json_version_url(url)
251
+ url = Addressable::URI.parse(url).dup
252
+ url.scheme = HTTP_SCHEMES.fetch(url.scheme, url.scheme)
253
+ url.path = "/json/version"
254
+ url
255
+ end
256
+
257
+ def reachable_ws_url(ws_url, endpoint)
258
+ return unless ws_url
259
+
260
+ ws_url = Addressable::URI.parse(ws_url)
261
+ return ws_url.to_s unless LOCAL_HOSTS.include?(ws_url.host)
262
+
263
+ url = Addressable::URI.parse(endpoint).dup
264
+ url.scheme = WS_SCHEMES.fetch(url.scheme, url.scheme)
265
+ url.path = ws_url.path
266
+ url.to_s
267
+ end
263
268
  end
264
269
  end
265
270
  end
@@ -29,7 +29,7 @@ module Ferrum
29
29
  delegate %i[default_context] => :contexts
30
30
  delegate %i[targets create_target page pages windows workers service_workers attach_target] => :default_context
31
31
  delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
32
- at_css at_xpath css xpath current_url current_title url title
32
+ at_css at_xpath css xpath wait_for_selector current_url current_title url title
33
33
  body doctype content=
34
34
  headers cookies network accessibility downloads
35
35
  mouse keyboard
@@ -103,9 +103,8 @@ module Ferrum
103
103
  # why you have slow responses and fix or block them rather than turn this
104
104
  # setting off.
105
105
  #
106
- # @option options [:chrome, :firefox] :browser_name (:chrome)
107
- # Sets the browser's name. **Note:** only experimental support for
108
- # `:firefox` for now.
106
+ # @option options [:chrome, :opera, :edge] :browser_name (:chrome)
107
+ # Sets the browser's name.
109
108
  #
110
109
  # @option options [String] :browser_path
111
110
  # Path to Chrome binary, you can also set ENV variable as
@@ -238,14 +237,23 @@ module Ferrum
238
237
  #
239
238
  # Terminates the browser process and closes the client connection.
240
239
  #
241
- def quit
240
+ # @param [Boolean] wait
241
+ # Whether to block until the process is confirmed dead and its user
242
+ # data directory removed (the default), or return immediately and run
243
+ # that cleanup on a background thread instead. See {Process#stop}.
244
+ #
245
+ # @return [Thread, nil]
246
+ # The background cleanup thread when `wait: false`, `nil` otherwise.
247
+ #
248
+ def quit(wait: true)
242
249
  return unless @client
243
250
 
244
- contexts.close_connections
251
+ contexts&.close_connections
245
252
 
246
253
  @client.close
247
- @process.stop
254
+ thread = @process.stop(wait: wait)
248
255
  @client = @process = @contexts = nil
256
+ thread
249
257
  end
250
258
 
251
259
  #
@@ -106,7 +106,7 @@ module Ferrum
106
106
  private
107
107
 
108
108
  def start
109
- @regular_thread = Utils::Thread.spawn(abort_on_exception: false) do
109
+ @regular_thread = Utils::Thread.spawn do
110
110
  loop do
111
111
  message = @regular.pop
112
112
  break unless message
@@ -115,7 +115,7 @@ module Ferrum
115
115
  end
116
116
  end
117
117
 
118
- @priority_thread = Utils::Thread.spawn(abort_on_exception: false) do
118
+ @priority_thread = Utils::Thread.spawn do
119
119
  loop do
120
120
  message = @priority.pop
121
121
  break unless message
@@ -133,6 +133,11 @@ module Ferrum
133
133
  @on[event]&.each_with_index do |block, index|
134
134
  # In case of multiple callbacks we provide current index and total
135
135
  block.call(params, index, total)
136
+ rescue StandardError => e
137
+ # A raising callback used to terminate the dispatch thread, and with it every
138
+ # event for the rest of the browser's life: targets stopped being registered
139
+ # and any page created later raised `NoSuchTargetError`. Report and carry on.
140
+ warn("Ferrum: #{event} callback raised #{e.class}: #{e.message}\n #{e.backtrace&.first}")
136
141
  end
137
142
  end
138
143
  end