ferrum 0.17.2 → 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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  4. data/lib/ferrum/accessibility.rb +106 -0
  5. data/lib/ferrum/browser/binary.rb +42 -2
  6. data/lib/ferrum/browser/command.rb +20 -2
  7. data/lib/ferrum/browser/options/base.rb +66 -0
  8. data/lib/ferrum/browser/options/chrome.rb +35 -2
  9. data/lib/ferrum/browser/options.rb +41 -2
  10. data/lib/ferrum/browser/process/killer.rb +188 -0
  11. data/lib/ferrum/browser/process.rb +128 -72
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +47 -17
  14. data/lib/ferrum/client/subscriber.rb +65 -2
  15. data/lib/ferrum/client/web_socket.rb +126 -13
  16. data/lib/ferrum/client.rb +225 -12
  17. data/lib/ferrum/context.rb +113 -7
  18. data/lib/ferrum/contexts.rb +188 -19
  19. data/lib/ferrum/cookies/cookie.rb +6 -0
  20. data/lib/ferrum/cookies.rb +5 -0
  21. data/lib/ferrum/dialog.rb +18 -2
  22. data/lib/ferrum/downloads.rb +54 -1
  23. data/lib/ferrum/errors.rb +103 -4
  24. data/lib/ferrum/frame/dom.rb +65 -0
  25. data/lib/ferrum/frame/runtime.rb +57 -15
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +83 -0
  29. data/lib/ferrum/keyboard.rb +25 -0
  30. data/lib/ferrum/mouse.rb +6 -0
  31. data/lib/ferrum/network/auth_request.rb +81 -2
  32. data/lib/ferrum/network/error.rb +15 -0
  33. data/lib/ferrum/network/exchange.rb +10 -0
  34. data/lib/ferrum/network/intercepted_request.rb +94 -2
  35. data/lib/ferrum/network/request.rb +1 -1
  36. data/lib/ferrum/network/response.rb +2 -0
  37. data/lib/ferrum/network.rb +121 -9
  38. data/lib/ferrum/node.rb +305 -15
  39. data/lib/ferrum/page/animation.rb +5 -0
  40. data/lib/ferrum/page/frames.rb +69 -7
  41. data/lib/ferrum/page/screencast.rb +5 -0
  42. data/lib/ferrum/page/screenshot.rb +55 -23
  43. data/lib/ferrum/page/stream.rb +71 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +148 -45
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +130 -3
  49. data/lib/ferrum/utils/attempt.rb +20 -0
  50. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  51. data/lib/ferrum/utils/event.rb +14 -0
  52. data/lib/ferrum/utils/platform.rb +21 -0
  53. data/lib/ferrum/utils/thread.rb +15 -1
  54. data/lib/ferrum/version.rb +1 -1
  55. data/lib/ferrum/worker.rb +125 -0
  56. data/lib/ferrum.rb +7 -0
  57. metadata +6 -16
  58. data/lib/ferrum/browser/options/firefox.rb +0 -38
@@ -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
@@ -48,7 +106,7 @@ module Ferrum
48
106
  private
49
107
 
50
108
  def start
51
- @regular_thread = Utils::Thread.spawn(abort_on_exception: false) do
109
+ @regular_thread = Utils::Thread.spawn do
52
110
  loop do
53
111
  message = @regular.pop
54
112
  break unless message
@@ -57,7 +115,7 @@ module Ferrum
57
115
  end
58
116
  end
59
117
 
60
- @priority_thread = Utils::Thread.spawn(abort_on_exception: false) do
118
+ @priority_thread = Utils::Thread.spawn do
61
119
  loop do
62
120
  message = @priority.pop
63
121
  break unless message
@@ -75,6 +133,11 @@ module Ferrum
75
133
  @on[event]&.each_with_index do |block, index|
76
134
  # In case of multiple callbacks we provide current index and total
77
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}")
78
141
  end
79
142
  end
80
143
  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
@@ -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,43 +79,141 @@ 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
 
75
- def send_message(data)
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
+ # @param [Numeric, nil] timeout
110
+ # How long the browser may go without accepting any of the frame's
111
+ # bytes. `nil` waits for as long as it takes.
112
+ #
113
+ # @return [void]
114
+ #
115
+ # @raise [Ferrum::DeadBrowserError]
116
+ # If the browser accepted no data for `timeout` seconds. The connection
117
+ # is closed, since the frame was cut off midway.
118
+ #
119
+ def send_message(data, timeout = nil)
76
120
  @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
77
121
 
78
122
  json = data.to_json
79
- @driver.text(json)
123
+ @driver_mutex.synchronize do
124
+ @write_timeout = timeout
125
+ @driver.text(json)
126
+ ensure
127
+ @write_timeout = nil
128
+ end
80
129
  @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
81
130
  end
82
131
 
132
+ #
133
+ # Writes raw bytes to the underlying socket. Called by
134
+ # `websocket-driver` to emit frames. Closes {#messages} instead of
135
+ # raising if the connection has already been torn down.
136
+ #
137
+ # A browser that stops reading its socket (hung, or stopped) would block
138
+ # a plain write forever, holding the lock every other command needs to be
139
+ # sent. So the write gives up once the browser accepts no data for the
140
+ # timeout of the command being sent.
141
+ #
142
+ # @param [String] data
143
+ # The raw bytes to write.
144
+ #
145
+ # @return [void]
146
+ #
147
+ # @raise [Ferrum::DeadBrowserError]
148
+ # If the browser accepted no data for the command's timeout.
149
+ #
83
150
  def write(data)
84
- @sock.write(data)
151
+ until data.empty?
152
+ written = @sock.write_nonblock(data, exception: false)
153
+ if written.is_a?(Integer)
154
+ data = data.byteslice(written..)
155
+ elsif !wait_for_socket(written)
156
+ abandon_stalled_write
157
+ end
158
+ end
85
159
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
86
160
  @messages.close
87
161
  end
88
162
 
163
+ #
164
+ # Closes the websocket connection by sending a close frame.
165
+ #
166
+ # @return [void]
167
+ #
89
168
  def close
90
- @driver.close
169
+ @driver_mutex.synchronize { @driver.close }
170
+ end
171
+
172
+ #
173
+ # Closes the underlying socket whether or not the close handshake ever
174
+ # completed.
175
+ #
176
+ # {#close} only sends a close frame; the socket itself is closed by
177
+ # {#on_close}, which runs when the browser answers with a close frame of
178
+ # its own. A browser that is already dead never answers, so without this
179
+ # the socket stays open until GC finalizes it -- long enough to exhaust
180
+ # the process's file descriptors when browsers are restarted in a loop.
181
+ #
182
+ # @return [void]
183
+ #
184
+ def force_close
185
+ @sock.close unless @sock.closed?
186
+ rescue IOError, SystemCallError
187
+ # nop
91
188
  end
92
189
 
93
190
  private
94
191
 
192
+ def wait_for_socket(state)
193
+ readers, writers = state == :wait_readable ? [[@sock], nil] : [nil, [@sock]]
194
+ IO.select(readers, writers, nil, @write_timeout)
195
+ end
196
+
197
+ def abandon_stalled_write
198
+ timeout = @write_timeout
199
+ @messages.close
200
+ @sock.close
201
+ raise DeadBrowserError, "Browser accepted no data for #{timeout}s and is likely stuck, connection closed"
202
+ end
203
+
95
204
  def start
96
205
  @thread = Utils::Thread.spawn do
97
206
  loop do
98
207
  data = @sock.readpartial(512)
99
208
  break unless data
100
209
 
101
- @driver.parse(data)
210
+ @driver_mutex.synchronize { @driver.parse(data) }
102
211
  end
103
212
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
213
+ # The browser went away, nothing to report.
214
+ rescue StandardError => e
215
+ warn("Ferrum: websocket reader stopped, #{e.class}: #{e.message}\n #{e.backtrace&.first}")
216
+ ensure
104
217
  @messages.close
105
218
  end
106
219
  end
data/lib/ferrum/client.rb CHANGED
@@ -7,9 +7,29 @@ require "ferrum/client/web_socket"
7
7
  require "ferrum/utils/thread"
8
8
 
9
9
  module Ferrum
10
+ #
11
+ # A thin wrapper around {Client} that scopes commands and events to a
12
+ # single CDP session (a `sessionId` obtained via `Target.attachToTarget`).
13
+ # Targets connect through one of these rather than the top-level {Client}
14
+ # directly, so their commands/events don't leak into other sessions.
15
+ # Method calls not defined here are forwarded to the underlying {Client}.
16
+ #
10
17
  class SessionClient
11
18
  attr_reader :client, :session_id
12
19
 
20
+ #
21
+ # Builds the internal event key used to route an event to a specific
22
+ # session, joining the CDP event name with a session id (or leaving it
23
+ # session-less when `session_id` is `nil`).
24
+ #
25
+ # @param [String] event
26
+ # The CDP event name, e.g. `"Page.loadEventFired"`.
27
+ #
28
+ # @param [String, nil] session_id
29
+ # The target session id, or `nil` for the browser-wide session.
30
+ #
31
+ # @return [String]
32
+ #
13
33
  def self.event_name(event, session_id)
14
34
  [event, session_id].compact.join("_")
15
35
  end
@@ -19,31 +39,93 @@ module Ferrum
19
39
  @session_id = session_id
20
40
  end
21
41
 
22
- def command(method, async: false, **params)
42
+ #
43
+ # Sends a CDP command scoped to this session.
44
+ #
45
+ # @param [String] method
46
+ # The CDP method name, e.g. `"Page.navigate"`.
47
+ #
48
+ # @param [Boolean] async
49
+ # Whether to send the command without waiting for a response.
50
+ #
51
+ # @param [Hash] params
52
+ # The command's parameters.
53
+ #
54
+ # @param [Numeric, nil] timeout
55
+ # How long to wait for this command's response, overriding
56
+ # {Browser::Options#protocol_timeout}. See {Client#send_message}.
57
+ #
58
+ # @return [Boolean, Hash]
59
+ # `true` when sent asynchronously, otherwise the command's result.
60
+ #
61
+ def command(method, async: false, timeout: nil, **params)
23
62
  message = build_message(method, params)
24
- @client.send_message(message, async: async)
63
+ @client.send_message(message, async: async, timeout: timeout)
25
64
  end
26
65
 
66
+ #
67
+ # Subscribes to a CDP event scoped to this session.
68
+ #
69
+ # @param [String] event
70
+ # The CDP event name.
71
+ #
72
+ # @return [Integer]
73
+ # The subscription id, used to unsubscribe via {#off}.
74
+ #
27
75
  def on(event, &)
28
76
  @client.on(event_name(event), &)
29
77
  end
30
78
 
79
+ #
80
+ # Unsubscribes from a CDP event scoped to this session.
81
+ #
82
+ # @param [String] event
83
+ # The CDP event name.
84
+ #
85
+ # @param [Integer] id
86
+ # The subscription id returned by {#on}.
87
+ #
88
+ # @return [void]
89
+ #
31
90
  def off(event, id)
32
91
  @client.off(event_name(event), id)
33
92
  end
34
93
 
94
+ # Whether there's at least one callback registered for the event, scoped
95
+ # to this session.
96
+ #
97
+ # @param [String] event
98
+ # The CDP event name.
99
+ #
100
+ # @return [Boolean]
35
101
  def subscribed?(event)
36
102
  @client.subscribed?(event_name(event))
37
103
  end
38
104
 
105
+ # Supports {#method_missing} delegation by reporting the underlying
106
+ # {Client}'s methods as responded to.
107
+ #
108
+ # @return [Boolean]
39
109
  def respond_to_missing?(name, include_private)
40
110
  @client.respond_to?(name, include_private)
41
111
  end
42
112
 
113
+ #
114
+ # Delegates any method not defined on `SessionClient` to the underlying
115
+ # {Client}, e.g. `subscribed?`.
116
+ #
117
+ # @return [untyped]
118
+ #
43
119
  def method_missing(name, ...)
44
120
  @client.send(name, ...)
45
121
  end
46
122
 
123
+ #
124
+ # Removes all event callbacks registered for this session from the
125
+ # underlying client's subscriber.
126
+ #
127
+ # @return [void]
128
+ #
47
129
  def close
48
130
  @client.subscriber.clear(session_id: session_id)
49
131
  end
@@ -59,15 +141,22 @@ module Ferrum
59
141
  end
60
142
  end
61
143
 
144
+ #
145
+ # The low-level CDP client. Owns the {WebSocket} connection to the browser,
146
+ # assigns command ids, matches responses back to their pending commands and
147
+ # dispatches incoming events to the {Subscriber}. {SessionClient} builds on
148
+ # top of it to scope commands/events to a particular target's session.
149
+ #
62
150
  class Client
63
151
  extend Forwardable
64
152
 
65
- delegate %i[timeout timeout=] => :options
153
+ delegate %i[protocol_timeout protocol_timeout=] => :options
66
154
 
67
155
  attr_reader :ws_url, :options, :subscriber
68
156
 
69
157
  def initialize(ws_url, options)
70
158
  @command_id = 0
159
+ @command_id_mutex = Mutex.new
71
160
  @ws_url = ws_url
72
161
  @options = options
73
162
  @pendings = Concurrent::Hash.new
@@ -77,24 +166,75 @@ module Ferrum
77
166
  start
78
167
  end
79
168
 
80
- def command(method, async: false, **params)
169
+ #
170
+ # Sends a CDP command to the browser-wide session.
171
+ #
172
+ # @param [String] method
173
+ # The CDP method name, e.g. `"Target.createTarget"`.
174
+ #
175
+ # @param [Boolean] async
176
+ # Whether to send the command without waiting for a response.
177
+ #
178
+ # @param [Hash] params
179
+ # The command's parameters.
180
+ #
181
+ # @param [Numeric, nil] timeout
182
+ # How long to wait for this command's response, overriding
183
+ # {Browser::Options#protocol_timeout}. See {#send_message}.
184
+ #
185
+ # @return [Boolean, Hash]
186
+ # `true` when sent asynchronously, otherwise the command's result.
187
+ #
188
+ def command(method, async: false, timeout: nil, **params)
81
189
  message = build_message(method, params)
82
- send_message(message, async: async)
190
+ send_message(message, async: async, timeout: timeout)
83
191
  end
84
192
 
85
- def send_message(message, async:)
193
+ #
194
+ # Sends a raw CDP message over the websocket. Synchronous calls block
195
+ # until a matching response arrives, or `timeout` elapses, defaulting to
196
+ # `protocol_timeout` (delegated to {Browser::Options#protocol_timeout}).
197
+ # That default is the transport-level budget for internal CDP bookkeeping
198
+ # (e.g. `Target.createTarget`). {Page#command} overrides
199
+ # this back to `timeout`, or a caller-supplied budget (e.g. `#pdf`/
200
+ # `#screenshot`'s own `timeout:` argument), for the user-facing commands
201
+ # it issues -- some of which (e.g. `Page.navigate`, `Page.printToPDF`)
202
+ # rely on their own response latency to detect a stuck operation.
203
+ #
204
+ # @param [Hash] message
205
+ # The message to send, must include an `:id` key.
206
+ #
207
+ # @param [Boolean] async
208
+ # Whether to return immediately instead of waiting for a response.
209
+ #
210
+ # @param [Numeric, nil] timeout
211
+ # How long to wait for the browser to take the message, and then for its
212
+ # response. Defaults to `protocol_timeout`.
213
+ #
214
+ # @return [Boolean, Hash]
215
+ # `true` when sent asynchronously, otherwise the parsed `"result"`
216
+ # from the response.
217
+ #
218
+ def send_message(message, async:, timeout: nil)
219
+ timeout ||= protocol_timeout
220
+
86
221
  if async
87
- @ws.send_message(message)
222
+ @ws.send_message(message, timeout)
88
223
  true
89
224
  else
225
+ raise DeadBrowserError if @ws.messages.closed?
226
+
90
227
  pending = Concurrent::IVar.new
91
228
  @pendings[message[:id]] = pending
92
- @ws.send_message(message)
93
- data = pending.value!(timeout)
94
- @pendings.delete(message[:id])
229
+ begin
230
+ @ws.send_message(message, timeout)
231
+ data = pending.value!(timeout)
232
+ ensure
233
+ @pendings.delete(message[:id])
234
+ end
95
235
 
96
236
  raise DeadBrowserError if data.nil? && @ws.messages.closed?
97
- raise TimeoutError unless data
237
+ raise TimeoutError.new(message[:method], session_id: message[:sessionId]) unless data
98
238
 
99
239
  error, response = data.values_at("error", "result")
100
240
  raise_browser_error(error) if error
@@ -102,30 +242,77 @@ module Ferrum
102
242
  end
103
243
  end
104
244
 
245
+ #
246
+ # Subscribes to a CDP event.
247
+ #
248
+ # @param [String] event
249
+ # The CDP event name.
250
+ #
251
+ # @return [Integer]
252
+ # The subscription id, used to unsubscribe via {#off}.
253
+ #
105
254
  def on(event, &)
106
255
  @subscriber.on(event, &)
107
256
  end
108
257
 
258
+ #
259
+ # Unsubscribes from a CDP event.
260
+ #
261
+ # @param [String] event
262
+ # The CDP event name.
263
+ #
264
+ # @param [Integer] id
265
+ # The subscription id returned by {#on}.
266
+ #
267
+ # @return [void]
268
+ #
109
269
  def off(event, id)
110
270
  @subscriber.off(event, id)
111
271
  end
112
272
 
273
+ # Whether there's at least one callback registered for the event.
274
+ #
275
+ # @param [String] event
276
+ # The CDP event name.
277
+ #
278
+ # @return [Boolean]
113
279
  def subscribed?(event)
114
280
  @subscriber.subscribed?(event)
115
281
  end
116
282
 
283
+ #
284
+ # Builds a client scoped to a given CDP session, e.g. a browsing
285
+ # context created via `Target.attachToTarget`.
286
+ #
287
+ # @param [String] session_id
288
+ # The CDP session id to scope commands and events to.
289
+ #
290
+ # @return [SessionClient]
291
+ #
117
292
  def session(session_id)
118
293
  SessionClient.new(self, session_id)
119
294
  end
120
295
 
296
+ #
297
+ # Closes the underlying websocket, drops pending commands and stops
298
+ # the message-processing thread and subscriber.
299
+ #
300
+ # @return [void]
301
+ #
121
302
  def close
122
303
  @ws.close
123
304
  # Give a thread some time to handle a tail of messages
124
305
  @pendings.clear
125
306
  @thread.kill unless @thread.join(1)
126
307
  @subscriber.close
308
+ @ws.force_close
127
309
  end
128
310
 
311
+ #
312
+ # Custom inspection that exposes internal state useful for debugging.
313
+ #
314
+ # @return [String]
315
+ #
129
316
  def inspect
130
317
  "#<#{self.class} " \
131
318
  "@command_id=#{@command_id.inspect} " \
@@ -133,6 +320,17 @@ module Ferrum
133
320
  "@ws=#{@ws.inspect}>"
134
321
  end
135
322
 
323
+ #
324
+ # Builds a CDP message hash with a fresh, thread-safe command id.
325
+ #
326
+ # @param [String] method
327
+ # The CDP method name.
328
+ #
329
+ # @param [Hash] params
330
+ # The command's parameters.
331
+ #
332
+ # @return [Hash]
333
+ #
136
334
  def build_message(method, params)
137
335
  { method: method, params: params }.merge(id: next_command_id)
138
336
  end
@@ -151,11 +349,26 @@ module Ferrum
151
349
  @pendings[message["id"]]&.set(message)
152
350
  end
153
351
  end
352
+ rescue StandardError => e
353
+ # Nothing delivers responses once this thread is gone, so the connection
354
+ # is done for. Close it rather than leave every command hanging.
355
+ warn("Ferrum: dispatch stopped, #{e.class}: #{e.message}\n #{e.backtrace&.first}")
356
+ @ws.messages.close
357
+ ensure
358
+ release_pendings
154
359
  end
155
360
  end
156
361
 
362
+ # Nothing is going to answer the commands still in flight once the socket
363
+ # is gone, release them instead of making each wait out its timeout.
364
+ def release_pendings
365
+ @pendings.each_value { |pending| pending.try_set(nil) }
366
+ end
367
+
368
+ # Locked so two concurrent commands never share an id and read each
369
+ # other's responses from @pendings.
157
370
  def next_command_id
158
- @command_id += 1
371
+ @command_id_mutex.synchronize { @command_id += 1 }
159
372
  end
160
373
 
161
374
  def raise_browser_error(error)