ferrum 0.17.1 → 0.18.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +9 -1369
  4. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  5. data/lib/ferrum/accessibility.rb +106 -0
  6. data/lib/ferrum/browser/binary.rb +41 -0
  7. data/lib/ferrum/browser/command.rb +20 -0
  8. data/lib/ferrum/browser/options/base.rb +69 -2
  9. data/lib/ferrum/browser/options/chrome.rb +88 -26
  10. data/lib/ferrum/browser/options/firefox.rb +32 -0
  11. data/lib/ferrum/browser/options.rb +43 -3
  12. data/lib/ferrum/browser/process.rb +56 -4
  13. data/lib/ferrum/browser/xvfb.rb +24 -0
  14. data/lib/ferrum/browser.rb +35 -9
  15. data/lib/ferrum/client/subscriber.rb +58 -0
  16. data/lib/ferrum/client/web_socket.rb +63 -12
  17. data/lib/ferrum/client.rb +206 -14
  18. data/lib/ferrum/context.rb +99 -4
  19. data/lib/ferrum/contexts.rb +136 -22
  20. data/lib/ferrum/cookies/cookie.rb +7 -1
  21. data/lib/ferrum/cookies.rb +5 -0
  22. data/lib/ferrum/dialog.rb +18 -2
  23. data/lib/ferrum/downloads.rb +52 -0
  24. data/lib/ferrum/errors.rb +62 -6
  25. data/lib/ferrum/frame/dom.rb +17 -0
  26. data/lib/ferrum/frame/runtime.rb +49 -8
  27. data/lib/ferrum/frame.rb +58 -1
  28. data/lib/ferrum/headers.rb +6 -0
  29. data/lib/ferrum/interceptable.rb +62 -0
  30. data/lib/ferrum/keyboard.rb +25 -0
  31. data/lib/ferrum/mouse.rb +6 -0
  32. data/lib/ferrum/network/auth_request.rb +81 -2
  33. data/lib/ferrum/network/error.rb +15 -0
  34. data/lib/ferrum/network/exchange.rb +10 -0
  35. data/lib/ferrum/network/intercepted_request.rb +94 -2
  36. data/lib/ferrum/network/request.rb +1 -1
  37. data/lib/ferrum/network/response.rb +16 -1
  38. data/lib/ferrum/network.rb +122 -10
  39. data/lib/ferrum/node.rb +305 -15
  40. data/lib/ferrum/page/animation.rb +7 -2
  41. data/lib/ferrum/page/frames.rb +69 -7
  42. data/lib/ferrum/page/screencast.rb +5 -0
  43. data/lib/ferrum/page/screenshot.rb +52 -20
  44. data/lib/ferrum/page/stream.rb +56 -0
  45. data/lib/ferrum/page/tracing.rb +6 -0
  46. data/lib/ferrum/page.rb +141 -46
  47. data/lib/ferrum/proxy.rb +52 -2
  48. data/lib/ferrum/rgba.rb +10 -0
  49. data/lib/ferrum/target.rb +124 -1
  50. data/lib/ferrum/utils/attempt.rb +20 -0
  51. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  52. data/lib/ferrum/utils/event.rb +14 -0
  53. data/lib/ferrum/utils/platform.rb +22 -1
  54. data/lib/ferrum/utils/thread.rb +12 -0
  55. data/lib/ferrum/version.rb +1 -1
  56. data/lib/ferrum/worker.rb +125 -0
  57. data/lib/ferrum.rb +7 -0
  58. metadata +8 -21
@@ -2,21 +2,28 @@
2
2
 
3
3
  module Ferrum
4
4
  class Browser
5
+ #
6
+ # Resolves and normalizes the options hash passed to {Browser.new},
7
+ # applying defaults for connection settings (host, port, timeouts),
8
+ # window size, and other launch behavior. Used throughout the browser to
9
+ # look up user-configured settings.
10
+ #
5
11
  class Options
6
12
  BROWSER_PORT = "0"
7
13
  BROWSER_HOST = "127.0.0.1"
8
14
  WINDOW_SIZE = [1024, 768].freeze
9
15
  BASE_URL_SCHEMA = %w[http https].freeze
10
16
  DEFAULT_TIMEOUT = ENV.fetch("FERRUM_DEFAULT_TIMEOUT", 5).to_i
17
+ DEFAULT_PROTOCOL_TIMEOUT = ENV.fetch("FERRUM_PROTOCOL_TIMEOUT", DEFAULT_TIMEOUT).to_i
11
18
  PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
12
19
  DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil?
13
20
 
14
21
  attr_reader :window_size, :logger, :ws_max_receive_size,
15
22
  :js_errors, :base_url, :slowmo, :pending_connection_errors,
16
23
  :url, :ws_url, :env, :process_timeout, :browser_name, :browser_path,
17
- :save_path, :proxy, :port, :host, :headless, :incognito, :browser_options,
24
+ :save_path, :proxy, :port, :host, :headless, :incognito, :dockerize, :browser_options,
18
25
  :ignore_default_browser_options, :xvfb, :flatten
19
- attr_accessor :timeout, :default_user_agent
26
+ attr_accessor :timeout, :protocol_timeout, :default_user_agent
20
27
 
21
28
  def initialize(options = nil)
22
29
  @options = Hash(options&.dup)
@@ -24,12 +31,14 @@ module Ferrum
24
31
  @port = @options.fetch(:port, BROWSER_PORT)
25
32
  @host = @options.fetch(:host, BROWSER_HOST)
26
33
  @timeout = @options.fetch(:timeout, DEFAULT_TIMEOUT)
34
+ @protocol_timeout = @options.fetch(:protocol_timeout, DEFAULT_PROTOCOL_TIMEOUT)
27
35
  @window_size = @options.fetch(:window_size, WINDOW_SIZE)
28
36
  @js_errors = @options.fetch(:js_errors, false)
29
37
  @headless = @options.fetch(:headless, true)
30
38
  @incognito = @options.fetch(:incognito, true)
39
+ @dockerize = @options.fetch(:dockerize, false)
31
40
  @flatten = @options.fetch(:flatten, true)
32
- @pending_connection_errors = @options.fetch(:pending_connection_errors, true)
41
+ @pending_connection_errors = @options.fetch(:pending_connection_errors, false)
33
42
  @process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
34
43
  @slowmo = @options[:slowmo].to_f
35
44
 
@@ -51,16 +60,42 @@ module Ferrum
51
60
  @browser_options = @options.fetch(:browser_options, {}).freeze
52
61
  end
53
62
 
63
+ #
64
+ # Sets the base URL relative navigations are resolved against.
65
+ #
66
+ # @param [String] value
67
+ # An absolute URL including scheme, e.g. `"https://example.com"`.
68
+ #
69
+ # @return [Addressable::URI]
70
+ #
54
71
  def base_url=(value)
55
72
  @base_url = parse_base_url(value)
56
73
  end
57
74
 
75
+ #
76
+ # JS source to preload into the browser, read from `:extensions` option.
77
+ #
78
+ # @return [Array<String>]
79
+ # JS source code for each configured extension.
80
+ #
58
81
  def extensions
59
82
  @extensions ||= Array(@options[:extensions]).map do |extension|
60
83
  (extension.is_a?(Hash) && extension[:source]) || File.read(extension)
61
84
  end
62
85
  end
63
86
 
87
+ #
88
+ # Validates the `:proxy` option, if given.
89
+ #
90
+ # @param [Hash, nil] options
91
+ # The `:proxy` option as passed to {#initialize}.
92
+ #
93
+ # @return [Hash, nil]
94
+ # The same `options`, unchanged.
95
+ #
96
+ # @raise [ArgumentError]
97
+ # If `options` is not a `Hash`, or is a `Hash` without `:host` or `:port`.
98
+ #
64
99
  def validate_proxy(options)
65
100
  return unless options
66
101
 
@@ -73,6 +108,11 @@ module Ferrum
73
108
  options
74
109
  end
75
110
 
111
+ #
112
+ # Raw options hash used to initialize the browser.
113
+ #
114
+ # @return [Hash{Symbol => Object}]
115
+ #
76
116
  def to_h
77
117
  @options
78
118
  end
@@ -14,21 +14,40 @@ require "ferrum/utils/platform"
14
14
 
15
15
  module Ferrum
16
16
  class Browser
17
+ #
18
+ # Spawns and manages the lifecycle of the browser OS process: builds the
19
+ # launch {Command}, starts it (optionally under {Xvfb} for headful mode),
20
+ # waits for the CDP WebSocket endpoint to become available, and handles
21
+ # stopping/restarting the process and cleaning up its user data directory.
22
+ #
17
23
  class Process
18
24
  KILL_TIMEOUT = 2
19
25
  WAIT_KILLED = 0.05
20
26
 
21
- attr_reader :host, :port, :ws_url, :pid, :command,
22
- :default_user_agent, :browser_version, :protocol_version,
23
- :v8_version, :webkit_version, :xvfb
24
-
25
27
  extend Forwardable
28
+
26
29
  delegate path: :command
27
30
 
31
+ #
32
+ # Builds and starts a new browser process.
33
+ #
34
+ # @param [Array] args
35
+ # Arguments forwarded to {#initialize}.
36
+ #
37
+ # @return [Process]
38
+ #
28
39
  def self.start(*args)
29
40
  new(*args).tap(&:start)
30
41
  end
31
42
 
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
+ #
32
51
  def self.process_killer(pid)
33
52
  proc do
34
53
  if Utils::Platform.windows?
@@ -51,6 +70,14 @@ module Ferrum
51
70
  end
52
71
  end
53
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
+ #
54
81
  def self.directory_remover(path)
55
82
  proc {
56
83
  begin
@@ -61,6 +88,10 @@ module Ferrum
61
88
  }
62
89
  end
63
90
 
91
+ attr_reader :host, :port, :ws_url, :pid, :command,
92
+ :default_user_agent, :browser_version, :protocol_version,
93
+ :v8_version, :webkit_version, :xvfb
94
+
64
95
  def initialize(options)
65
96
  @pid = @xvfb = @user_data_dir = nil
66
97
 
@@ -82,6 +113,11 @@ module Ferrum
82
113
  @command = Command.build(options, tmpdir)
83
114
  end
84
115
 
116
+ #
117
+ # Spawns the browser process and waits for it to become reachable over CDP.
118
+ #
119
+ # @return [void]
120
+ #
85
121
  def start
86
122
  # Don't do anything as browser is already running as external process.
87
123
  return if ws_url
@@ -108,6 +144,12 @@ module Ferrum
108
144
  end
109
145
  end
110
146
 
147
+ #
148
+ # Kills the browser process (and Xvfb, if running) and removes the user
149
+ # data directory.
150
+ #
151
+ # @return [void]
152
+ #
111
153
  def stop
112
154
  if @pid
113
155
  kill(@pid)
@@ -119,11 +161,21 @@ module Ferrum
119
161
  ObjectSpace.undefine_finalizer(self)
120
162
  end
121
163
 
164
+ #
165
+ # Stops and starts the browser process again.
166
+ #
167
+ # @return [void]
168
+ #
122
169
  def restart
123
170
  stop
124
171
  start
125
172
  end
126
173
 
174
+ #
175
+ # Custom inspection that omits noisy internal command details.
176
+ #
177
+ # @return [String]
178
+ #
127
179
  def inspect
128
180
  "#<#{self.class} " \
129
181
  "@user_data_dir=#{@user_data_dir.inspect} " \
@@ -2,10 +2,24 @@
2
2
 
3
3
  module Ferrum
4
4
  class Browser
5
+ #
6
+ # Manages an Xvfb virtual display server, letting a "headful" browser run
7
+ # in environments without a real display (e.g. CI). Spawned by {Process}
8
+ # when the `:xvfb` option is set, and exposes the `DISPLAY` environment
9
+ # variable the browser process needs to attach to it.
10
+ #
5
11
  class Xvfb
6
12
  NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \
7
13
  "it with your package manager"
8
14
 
15
+ #
16
+ # Builds and starts a new Xvfb instance.
17
+ #
18
+ # @param [Array] args
19
+ # Arguments forwarded to {#initialize}.
20
+ #
21
+ # @return [Xvfb]
22
+ #
9
23
  def self.start(*args)
10
24
  new(*args).tap(&:start)
11
25
  end
@@ -20,11 +34,21 @@ module Ferrum
20
34
  @display_id = (Time.now.to_f * 1000).to_i % 100_000_000
21
35
  end
22
36
 
37
+ #
38
+ # Spawns the Xvfb process on the configured display.
39
+ #
40
+ # @return [void]
41
+ #
23
42
  def start
24
43
  @pid = ::Process.spawn("#{@path} :#{display_id} -screen 0 #{screen_size}")
25
44
  ::Process.detach(@pid)
26
45
  end
27
46
 
47
+ #
48
+ # Environment variables needed to point the browser at this Xvfb display.
49
+ #
50
+ # @return [Hash{String => String}]
51
+ #
28
52
  def to_env
29
53
  { "DISPLAY" => ":#{display_id}" }
30
54
  end
@@ -3,7 +3,7 @@
3
3
  require "base64"
4
4
  require "forwardable"
5
5
  require "ferrum/page"
6
- require "ferrum/proxy"
6
+ require "ferrum/worker"
7
7
  require "ferrum/client"
8
8
  require "ferrum/contexts"
9
9
  require "ferrum/browser/xvfb"
@@ -13,14 +13,25 @@ require "ferrum/browser/binary"
13
13
  require "ferrum/browser/version_info"
14
14
 
15
15
  module Ferrum
16
+ #
17
+ # The main entry point of the library. Instantiating it spawns (or connects
18
+ # to) a browser process and opens a CDP connection to it, exposing a single
19
+ # default {Page} that most of the top-level methods (`go_to`, `at_css`,
20
+ # `screenshot`, etc.) are delegated to for convenience.
21
+ #
22
+ # @note A `Browser` can host multiple {Context}s (like incognito profiles)
23
+ # and multiple {Page}s within each of them, reachable through {#contexts}
24
+ # and {#create_page}.
25
+ #
16
26
  class Browser
17
27
  extend Forwardable
28
+
18
29
  delegate %i[default_context] => :contexts
19
- delegate %i[targets create_target page pages windows] => :default_context
30
+ delegate %i[targets create_target page pages windows workers service_workers attach_target] => :default_context
20
31
  delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
21
32
  at_css at_xpath css xpath current_url current_title url title
22
33
  body doctype content=
23
- headers cookies network downloads
34
+ headers cookies network accessibility downloads
24
35
  mouse keyboard
25
36
  screenshot pdf mhtml viewport_size device_pixel_ratio
26
37
  start_screencast stop_screencast
@@ -33,7 +44,8 @@ module Ferrum
33
44
 
34
45
  attr_reader :client, :process, :contexts, :options
35
46
 
36
- delegate %i[timeout timeout= base_url base_url= default_user_agent default_user_agent= extensions] => :options
47
+ delegate %i[timeout timeout= protocol_timeout protocol_timeout=
48
+ base_url base_url= default_user_agent default_user_agent= extensions] => :options
37
49
  delegate %i[command] => :client
38
50
 
39
51
  #
@@ -48,6 +60,9 @@ module Ferrum
48
60
  # @option options [Boolean] :incognito (true)
49
61
  # Create an incognito profile for the browser startup window.
50
62
  #
63
+ # @option options [Boolean] :dockerize (false)
64
+ # Add CLI flags to a browser to run in a container.
65
+ #
51
66
  # @option options [Boolean] :xvfb (false)
52
67
  # Run browser in a virtual framebuffer.
53
68
  #
@@ -66,13 +81,18 @@ module Ferrum
66
81
  # When present, debug output is written to this object.
67
82
  #
68
83
  # @option options [Integer, Float] :slowmo
69
- # Set a delay in seconds to wait before sending command.
70
- # Useful companion of headless option, so that you have time to see
84
+ # Set a delay in seconds to wait before sending a command.
85
+ # Useful companion of a headless option, so that you have time to see
71
86
  # changes.
72
87
  #
73
88
  # @option options [Numeric] :timeout (5)
74
- # The number of seconds we'll wait for a response when communicating with
75
- # browser.
89
+ # The number of seconds we'll wait for a response when communicating
90
+ # with the browser: navigations, JS evaluation, DOM queries, dispatching input, etc.
91
+ #
92
+ # @option options [Numeric] :protocol_timeout (5)
93
+ # The number of seconds we'll wait for an individual internal CDP
94
+ # bookkeeping call to respond, e.g. `Target.createTarget`,
95
+ # `Target.attachToTarget`. These normally resolve in milliseconds.
76
96
  #
77
97
  # @option options [Boolean] :js_errors
78
98
  # When true, JavaScript errors get re-raised in Ruby.
@@ -168,7 +188,7 @@ module Ferrum
168
188
  ensure
169
189
  if block_given?
170
190
  page&.close
171
- context.dispose if new_context
191
+ context&.dispose if new_context
172
192
  end
173
193
  end
174
194
 
@@ -207,11 +227,17 @@ module Ferrum
207
227
  contexts.reset
208
228
  end
209
229
 
230
+ #
231
+ # Restarts the browser process, keeping the same options.
232
+ #
210
233
  def restart
211
234
  quit
212
235
  start
213
236
  end
214
237
 
238
+ #
239
+ # Terminates the browser process and closes the client connection.
240
+ #
215
241
  def quit
216
242
  return unless @client
217
243
 
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Ferrum
4
4
  class Client
5
+ #
6
+ # Dispatches incoming CDP events to registered callbacks. Messages are
7
+ # queued and processed on dedicated threads, with `Fetch.requestPaused`
8
+ # and `Fetch.authRequired` given priority so request interception isn't
9
+ # delayed behind other events.
10
+ #
5
11
  class Subscriber
6
12
  INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze
7
13
 
@@ -13,6 +19,16 @@ module Ferrum
13
19
  start
14
20
  end
15
21
 
22
+ #
23
+ # Enqueues an incoming CDP message for dispatch to subscribers.
24
+ # `Fetch.requestPaused`/`Fetch.authRequired` messages jump the regular
25
+ # queue so request interception isn't delayed behind other events.
26
+ #
27
+ # @param [Hash] message
28
+ # The raw CDP message, as parsed from the websocket.
29
+ #
30
+ # @return [void]
31
+ #
16
32
  def <<(message)
17
33
  if INTERRUPTIONS.include?(message["method"])
18
34
  @priority.push(message)
@@ -21,26 +37,68 @@ module Ferrum
21
37
  end
22
38
  end
23
39
 
40
+ #
41
+ # Registers a callback for a CDP event.
42
+ #
43
+ # @param [String] event
44
+ # The event key, as built by {SessionClient.event_name}.
45
+ #
46
+ # @return [Integer]
47
+ # The callback's index within the event's callback list, used to
48
+ # unsubscribe via {#off}.
49
+ #
24
50
  def on(event, &block)
25
51
  @on[event] ||= Concurrent::Array.new
26
52
  @on[event] << block
27
53
  @on[event].index(block)
28
54
  end
29
55
 
56
+ #
57
+ # Unregisters a callback for a CDP event.
58
+ #
59
+ # @param [String] event
60
+ # The event key, as built by {SessionClient.event_name}.
61
+ #
62
+ # @param [Integer] id
63
+ # The callback's index, as returned by {#on}.
64
+ #
65
+ # @return [Boolean]
66
+ #
30
67
  def off(event, id)
31
68
  @on[event].delete_at(id)
32
69
  true
33
70
  end
34
71
 
72
+ #
73
+ # Whether there's at least one callback registered for the event.
74
+ #
75
+ # @param [String] event
76
+ # The event key, as built by {SessionClient.event_name}.
77
+ #
78
+ # @return [Boolean]
79
+ #
35
80
  def subscribed?(event)
36
81
  @on.key?(event)
37
82
  end
38
83
 
84
+ #
85
+ # Stops the regular and priority dispatch threads.
86
+ #
87
+ # @return [void]
88
+ #
39
89
  def close
40
90
  @regular_thread&.kill
41
91
  @priority_thread&.kill
42
92
  end
43
93
 
94
+ #
95
+ # Removes all callbacks registered for a given session.
96
+ #
97
+ # @param [String] session_id
98
+ # The session id to match against registered event keys.
99
+ #
100
+ # @return [void]
101
+ #
44
102
  def clear(session_id:)
45
103
  @on.delete_if { |k, _| k.match?(session_id) }
46
104
  end
@@ -6,6 +6,12 @@ require "websocket/driver"
6
6
 
7
7
  module Ferrum
8
8
  class Client
9
+ #
10
+ # Low-level WebSocket connection to the browser's CDP endpoint. Opens
11
+ # the raw TCP/TLS socket, drives the `websocket-driver` handshake and
12
+ # framing, and exposes a queue of parsed incoming messages alongside
13
+ # methods to send commands and close the connection.
14
+ #
9
15
  class WebSocket
10
16
  WEBSOCKET_BUG_SLEEP = 0.05
11
17
  DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
@@ -19,7 +25,7 @@ module Ferrum
19
25
  uri = URI.parse(@url)
20
26
  port = uri.port || DEFAULT_PORTS[uri.scheme]
21
27
 
22
- if port == 443
28
+ if port == 443 || url.scheme == "wss"
23
29
  tcp = TCPSocket.new(uri.host, port)
24
30
  ssl_context = OpenSSL::SSL::SSLContext.new
25
31
  @sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
@@ -30,7 +36,10 @@ module Ferrum
30
36
  end
31
37
 
32
38
  max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
33
- @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
39
+ @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
40
+ # websocket-driver holds no locks and is called from many threads: commands from
41
+ # callers, pong/close replies from the reader. One lock keeps frames from interleaving.
42
+ @driver_mutex = Mutex.new
34
43
  @messages = Queue.new
35
44
 
36
45
  @screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
@@ -41,22 +50,28 @@ module Ferrum
41
50
 
42
51
  start
43
52
 
44
- @driver.start
53
+ @driver_mutex.synchronize { @driver.start }
45
54
  end
46
55
 
56
+ #
57
+ # Handles the driver's `:open` event.
58
+ #
59
+ # @return [void]
60
+ #
47
61
  def on_open(_event)
48
62
  # https://github.com/faye/websocket-driver-ruby/issues/46
49
63
  sleep(WEBSOCKET_BUG_SLEEP)
50
64
  end
51
65
 
66
+ #
67
+ # Handles the driver's `:message` event: parses the incoming frame
68
+ # as JSON and pushes it onto {#messages}. Malformed payloads are
69
+ # dropped rather than raised, to avoid crashing the reader thread.
70
+ #
71
+ # @return [void]
72
+ #
52
73
  def on_message(event)
53
74
  data = safely_parse_json(event.data)
54
- # If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
55
- # don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
56
- # anything or skip the background notification, but at least we don't crash the thread that crashes the main
57
- # thread and the application.
58
- @messages.push(data) if data
59
-
60
75
  output = event.data
61
76
  if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")]
62
77
  @screenshot_commands.delete(data&.dig("id"))
@@ -64,30 +79,66 @@ module Ferrum
64
79
  end
65
80
 
66
81
  @logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
82
+
83
+ # If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
84
+ # don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
85
+ # anything or skip the background notification, but at least we don't crash the thread that crashes the main
86
+ # thread and the application.
87
+ @messages.push(data) if data
67
88
  end
68
89
 
90
+ #
91
+ # Handles the driver's `:close` event: closes the message queue and
92
+ # underlying socket, then kills the reader thread.
93
+ #
94
+ # @return [void]
95
+ #
69
96
  def on_close(_event)
70
97
  @messages.close
71
98
  @sock.close
72
99
  @thread.kill
73
100
  end
74
101
 
102
+ #
103
+ # Serializes a CDP command to JSON and sends it as a websocket text
104
+ # frame.
105
+ #
106
+ # @param [Hash] data
107
+ # The message to send, must include an `:id` key.
108
+ #
109
+ # @return [void]
110
+ #
75
111
  def send_message(data)
76
112
  @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
77
113
 
78
114
  json = data.to_json
79
- @driver.text(json)
115
+ @driver_mutex.synchronize { @driver.text(json) }
80
116
  @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
81
117
  end
82
118
 
119
+ #
120
+ # Writes raw bytes to the underlying socket. Called by
121
+ # `websocket-driver` to emit frames. Closes {#messages} instead of
122
+ # raising if the connection has already been torn down.
123
+ #
124
+ # @param [String] data
125
+ # The raw bytes to write.
126
+ #
127
+ # @return [void]
128
+ #
83
129
  def write(data)
84
130
  @sock.write(data)
85
131
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
86
132
  @messages.close
87
133
  end
88
134
 
135
+ #
136
+ # Closes the websocket connection by sending a close frame.
137
+ #
138
+ # @return [void]
139
+ #
89
140
  def close
90
- @driver.close
141
+ @driver_mutex.synchronize { @driver.close }
91
142
  end
92
143
 
93
144
  private
@@ -98,7 +149,7 @@ module Ferrum
98
149
  data = @sock.readpartial(512)
99
150
  break unless data
100
151
 
101
- @driver.parse(data)
152
+ @driver_mutex.synchronize { @driver.parse(data) }
102
153
  end
103
154
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
104
155
  @messages.close