freeswitch-esl 0.1.0 → 0.2.1

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: 1f837d66f54cb1b978273c193f63ae772a2c967943b330130e77bd56335d6dd5
4
- data.tar.gz: 3e71ae84d554456aa359483b3d8a2923cba563904e03ecb1780cf94c440745bc
3
+ metadata.gz: 544ee32390c285ff1c6f1f1baa0d9ba7485b59725c6ce19b45dfd27bd96d3c7f
4
+ data.tar.gz: 120aaa84785e0f830943db345e370596f09f4f87cf07349d474e1a2b1e4c67bb
5
5
  SHA512:
6
- metadata.gz: 742e626b3428f49a9a3e0bc5bb10e85ce284baa6a57ce7cd13157351ad3475201548028c0e124d37d56f428d3a6b3e03dd5eaedd908a24e7795ec17d4cb2b3c2
7
- data.tar.gz: d4a46398d82a476311a0508780d6a15275b73b664657d22bd1236d488faaea62973992223a81d20b3fed29064a1f5e9d860040c0643307ee129f576e471d7dbf
6
+ metadata.gz: 2efb56ba53df80defef5566cd141a374544cd4bae9f0fec3208cd54006a3d9657f08306bfc410e01072e28e5817e152a89d134bdc96af9262bd19dcb48b9f838
7
+ data.tar.gz: f7a6e1142fa7a06082b73d508a840aeec92522a158d06811fad0c63375ba59b95b47eb1af66292720eb1c20144eed22daae20f10e35f10150cd9c0484d380836
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # freeswitch-esl
2
2
 
3
3
  `freeswitch-esl` is a Ruby gem for interacting with FreeSWITCH through ESL (Event Socket Library).
4
- This version is intentionally focused on one direction only: your Ruby process connects to `mod_event_socket` and uses a single shared client, `Freeswitch::ESL.client`.
4
+ This version is intentionally focused on one direction only: your Ruby process connects inbound to `mod_event_socket` and manages one or more `Freeswitch::ESL::Client` instances.
5
5
 
6
6
  The implementation keeps the public API small and groups protocol concerns separately:
7
7
 
@@ -56,7 +56,7 @@ require 'freeswitch-esl'
56
56
 
57
57
  ## Configuration
58
58
 
59
- Configure the library once and then use the shared client:
59
+ Configure the library once and then connect a client:
60
60
 
61
61
  ```ruby
62
62
  require 'logger'
@@ -66,10 +66,12 @@ Freeswitch::ESL.configure do |config|
66
66
  config.freeswitch.host = 'freeswitch.example.com'
67
67
  config.freeswitch.port = 8021
68
68
  config.freeswitch.password = 'ClueCon'
69
- config.freeswitch.timeout = 5
69
+ config.freeswitch.reconnect = true
70
70
  config.freeswitch.retry_delay = 1.0
71
71
  config.freeswitch.max_retries = 5
72
72
  config.logger = Logger.new($stdout)
73
+ config.logger.level = Logger::INFO
74
+ config.debug = false
73
75
  end
74
76
  ```
75
77
 
@@ -77,11 +79,12 @@ Defaults:
77
79
 
78
80
  - `config.freeswitch.host = '127.0.0.1'`
79
81
  - `config.freeswitch.port = 8021`
80
- - `config.freeswitch.password = 'ClueCon'`
81
- - `config.freeswitch.timeout = 5`
82
+ - `config.freeswitch.password = nil`
83
+ - `config.freeswitch.reconnect = true`
82
84
  - `config.freeswitch.retry_delay = 1.0`
83
- - `config.freeswitch.max_retries = 5`
84
- - `config.logger = nil`
85
+ - `config.freeswitch.max_retries = Float::INFINITY`
86
+ - `config.logger = Logger.new(IO::NULL)`
87
+ - `config.debug = false`
85
88
 
86
89
  ## FreeSWITCH Configuration
87
90
 
@@ -106,7 +109,7 @@ In the included Docker setup, `lan` is intentional because it allows both loopba
106
109
 
107
110
  ## Usage
108
111
 
109
- ### Shared client
112
+ ### Connect a client
110
113
 
111
114
  ```ruby
112
115
  require 'freeswitch-esl'
@@ -117,7 +120,7 @@ Freeswitch::ESL.configure do |config|
117
120
  config.freeswitch.password = 'ClueCon'
118
121
  end
119
122
 
120
- client = Freeswitch::ESL.client
123
+ client = Freeswitch::ESL::Client.connect
121
124
 
122
125
  client.subscribe('CHANNEL_CREATE', 'CHANNEL_HANGUP', 'DTMF')
123
126
  client.on('CHANNEL_CREATE') do |event|
@@ -128,10 +131,24 @@ client.on('DTMF') do |event|
128
131
  puts "digit=#{event['DTMF-Digit']} duration=#{event['DTMF-Duration']}"
129
132
  end
130
133
 
131
- response = client.api('status')
134
+ response = client.exec('status').response
132
135
  puts response.body
133
136
 
134
- Freeswitch::ESL.reset_client!
137
+ client.close
138
+ ```
139
+
140
+ `Freeswitch::ESL::Client.connect` builds a client with the global configuration,
141
+ opens the TCP socket, waits for FreeSWITCH `auth/request`, authenticates, subscribes
142
+ to the default event set, and returns a ready client instance.
143
+
144
+ ### Reconfigure an existing client
145
+
146
+ ```ruby
147
+ client = Freeswitch::ESL::Client.connect
148
+
149
+ client.close
150
+ client.configure(freeswitch: { host: 'fs-backup.example.com', reconnect: true })
151
+ client.connect
135
152
  ```
136
153
 
137
154
  ### Background API
@@ -139,24 +156,51 @@ Freeswitch::ESL.reset_client!
139
156
  ```ruby
140
157
  require 'freeswitch-esl'
141
158
 
142
- client = Freeswitch::ESL.client
159
+ client = Freeswitch::ESL::Client.connect
160
+
161
+ command = client.exec('originate', 'sofia/default/1000 &park')
143
162
 
144
- job_uuid = client.bgapi('originate', 'sofia/default/1000 &park') do |event|
145
- puts "job #{event.job_uuid} finished"
146
- puts event.body
163
+ command.on_complete do |completed|
164
+ puts "job #{completed.job_uuid} finished with status=#{completed.status}"
165
+ puts completed.response.body if completed.response
147
166
  end
148
167
 
149
- puts "submitted job=#{job_uuid}"
168
+ puts "submitted job=#{command.job_uuid}"
169
+
170
+ command.wait
171
+ client.close
150
172
  ```
151
173
 
174
+ `Client#exec` wraps `bgapi` and returns a `Freeswitch::ESL::Command` object.
175
+ You can:
176
+
177
+ - call `command.wait` for blocking flow
178
+ - call `command.response` to block until completion and get the final `BACKGROUND_JOB` event
179
+ - inspect `command.status`, `command.ok?`, `command.failed?`, `command.timeout?`
180
+ - attach `command.on_complete { |cmd| ... }` for async completion handling
181
+
182
+ If you prefer lower-level primitives, `Freeswitch::ESL::Client` also exposes the inherited connection API:
183
+
184
+ - `send_command`
185
+ - `bgapi`
186
+ - `subscribe`
187
+ - `unsubscribe`
188
+ - `filter`
189
+ - `on`
190
+
152
191
  ### Auto reconnect
153
192
 
154
- Event handlers remain registered across reconnects. After reconnect, re-subscribe to events:
193
+ When `config.freeswitch.reconnect` is enabled, `Client#connect` retries on initial
194
+ connection/authentication failure and the client reconnects automatically after later
195
+ disconnects.
196
+
197
+ Event handlers remain registered across reconnects because the event dispatcher is
198
+ preserved. Subscriptions do not: after reconnect, re-subscribe to the events you need.
155
199
 
156
200
  ```ruby
157
201
  require 'freeswitch-esl'
158
202
 
159
- client = Freeswitch::ESL.client
203
+ client = Freeswitch::ESL::Client.connect
160
204
 
161
205
  client.on('CHANNEL_HANGUP') do |event|
162
206
  puts "hangup cause=#{event['Hangup-Cause']}"
@@ -169,6 +213,9 @@ end
169
213
  client.subscribe('CHANNEL_HANGUP')
170
214
  ```
171
215
 
216
+ `client.close` stops reconnect attempts immediately. The same client instance can be
217
+ connected again later with `client.connect`.
218
+
172
219
  ## Docker Compose Test Lab
173
220
 
174
221
  This repository includes a local FreeSWITCH environment for ESL experimentation.
@@ -180,7 +227,20 @@ Files included:
180
227
  - `docker/freeswitch/autoload_configs/event_socket.conf.xml`
181
228
  - `examples/inbound_status.rb`
182
229
 
183
- The compose file uses the public image `safarov/freeswitch:latest` and overlays only the ESL-specific configuration needed for local development.
230
+ The compose file builds FreeSWITCH locally from the official source repository release.
231
+ The Dockerfile is aligned with the upstream reference at
232
+ `signalwire/freeswitch/docker/examples/Debian11/Dockerfile`, adapted for Debian Bookworm and this project's runtime needs.
233
+ By default it uses `FS_VERSION=latest`.
234
+
235
+ To pin a specific release tag (recommended for reproducible environments), set the build arg in `docker-compose.yml`:
236
+
237
+ ```yaml
238
+ services:
239
+ freeswitch:
240
+ build:
241
+ args:
242
+ FS_VERSION: v1.10.12
243
+ ```
184
244
 
185
245
  ### Start FreeSWITCH
186
246
 
@@ -188,7 +248,7 @@ The compose file uses the public image `safarov/freeswitch:latest` and overlays
188
248
  docker compose up -d
189
249
  ```
190
250
 
191
- If you change image or base distro later, verify that the configuration directory is still `/etc/freeswitch`.
251
+ If you change image or base distro later, verify that the configuration directory is still `/usr/local/freeswitch/etc/freeswitch`.
192
252
 
193
253
  ### Check that FreeSWITCH is up
194
254
 
@@ -207,8 +267,9 @@ Freeswitch::ESL.configure do |config|
207
267
  config.freeswitch.password = 'ClueCon'
208
268
  end
209
269
 
210
- puts Freeswitch::ESL.client.api('status').body
211
- Freeswitch::ESL.reset_client!
270
+ client = Freeswitch::ESL::Client.connect
271
+ puts client.exec('status').response.body
272
+ client.close
212
273
  ```
213
274
 
214
275
  Or run the ready-made example:
@@ -8,26 +8,91 @@ module Freeswitch
8
8
  class Client < Connection
9
9
  include Freeswitch::ESL::Logger
10
10
 
11
+ # For full events list see https://github.com/signalwire/freeswitch/blob/master/src/switch_event.c
12
+ DEFAULT_EVENTS = [
13
+ # Events useful for channel state tracking
14
+ "CHANNEL_CREATE",
15
+ "CHANNEL_STATE",
16
+ "CHANNEL_CALLSTATE",
17
+ "CHANNEL_ANSWER",
18
+ "CHANNEL_BRIDGE",
19
+ "CHANNEL_UNBRIDGE",
20
+ "CHANNEL_HANGUP",
21
+ "CHANNEL_HANGUP_COMPLETE",
22
+ "CHANNEL_DESTROY",
23
+
24
+ # Extra CHANNEL events
25
+ "CHANNEL_EXECUTE",
26
+ "CHANNEL_EXECUTE_COMPLETE",
27
+ "CHANNEL_PROGRESS",
28
+ "CHANNEL_PROGRESS_MEDIA",
29
+
30
+ # Receive BACKGROUND_JOB events for bgapi commands.
31
+ "BACKGROUND_JOB",
32
+
33
+ # Other events that might be useful for various applications
34
+ "DTMF",
35
+ "PLAYBACK_START",
36
+ "PLAYBACK_STOP",
37
+ "RECORD_START",
38
+ "RECORD_STOP",
39
+ "HEARTBEAT",
40
+ "CUSTOM"
41
+ ].freeze
42
+
43
+ class << self
44
+ def connect(**)
45
+ new(**).connect
46
+ end
47
+ end
48
+
11
49
  def config
12
50
  @config ||= Freeswitch::ESL.configuration
13
51
  end
14
52
 
15
53
  def configure(**)
16
54
  close if instance_variable_defined?(:@socket) && @socket && !closed?
17
- @config = Freeswitch::ESL::Configuration.build(**)
55
+ config.update(**)
18
56
  @intentionally_closed = false
19
- establish_connection
20
57
  self
21
58
  end
22
59
 
23
- def initialize(freeswitch: {}, logger: nil) # rubocop:disable Lint/MissingSuper
60
+ def initialize(freeswitch: {}, logger: nil, debug: false) # rubocop:disable Lint/MissingSuper
24
61
  @reconnect_handlers = []
25
62
  @intentionally_closed = false
63
+ @ready = false
64
+ @mutex = Mutex.new
65
+ @ready_cv = ConditionVariable.new
66
+
67
+ configure(freeswitch:, logger:, debug:)
68
+ end
69
+
70
+ def connect
71
+ @intentionally_closed = false
72
+ config.freeswitch.reconnect ? attempt_reconnect(sync: true) : establish_connection
73
+
74
+ self
75
+ end
76
+
77
+ def wait_until_ready(timeout: nil, raise_error: true)
78
+ @mutex.synchronize do
79
+ return self if ready?
80
+
81
+ @ready_cv.wait(@mutex, timeout)
82
+
83
+ raise TimeoutError, "Timed out waiting for FreeSWITCH ESL client to be ready" if raise_error && !ready?
84
+ end
85
+
86
+ self
87
+ end
26
88
 
27
- configure(freeswitch:, logger:)
89
+ def ready?
90
+ @ready
28
91
  end
29
92
 
30
93
  def close
94
+ @mutex.synchronize { @ready = false }
95
+
31
96
  logger.info "Closing FreeSWITCH ESL client connection"
32
97
  @intentionally_closed = true
33
98
  if @reconnect_thread
@@ -44,14 +109,27 @@ module Freeswitch
44
109
  self
45
110
  end
46
111
 
47
- private
112
+ # Execute a command via bgapi and return a {Command} object.
113
+ #
114
+ # @param command [String] the FreeSWITCH API command (e.g. "originate")
115
+ # @param args [String, nil] command arguments
116
+ # @param timeout [Integer] seconds to wait for the bgapi result event
117
+ # @yieldparam cmd [Command] called on completion (success or failure)
118
+ # @return [Command]
119
+ def exec(command, *, timeout: Command::DEFAULT_TIMEOUT, raise_error: true, &)
120
+ cmd = Command.new(self, command, *, timeout:, raise_error:, &)
121
+ cmd.execute!
122
+
123
+ cmd
124
+ end
48
125
 
49
126
  def establish_connection
50
127
  logger.info "Connecting to FreeSWITCH ESL at #{config.freeswitch.host}:#{config.freeswitch.port}"
51
- socket = build_socket
52
- initialize_socket(socket)
53
- authenticate_with_config!
54
- logger.info "Authenticated with FreeSWITCH ESL"
128
+ initialize_socket(build_socket, debug: config.debug)
129
+ authenticate!
130
+ subscribe(*DEFAULT_EVENTS)
131
+ ready!
132
+ logger.info "FreeSWITCH ESL client authenticated and ready!"
55
133
  end
56
134
 
57
135
  def build_socket
@@ -60,21 +138,47 @@ module Freeswitch
60
138
  socket
61
139
  end
62
140
 
63
- def authenticate_with_config!
64
- authenticate!(config.freeswitch.password, timeout: config.freeswitch.timeout)
141
+ # Authenticate against FreeSWITCH after the initial auth/request message.
142
+ def authenticate!
143
+ # Wait for the initial auth/request message initiated by FreeSWITCH before sending the auth command.
144
+ # This ensures we don't send auth before FreeSWITCH is ready to receive it,
145
+ # which would cause an immediate disconnect.
146
+ logger.debug "Waiting for auth/request from FreeSWITCH..."
147
+ wait_for_auth_request(timeout: 10)
148
+
149
+ # Send the auth command and wait for the response to confirm authentication succeeded.
150
+ cmd = send_command("auth #{config.freeswitch.password}", timeout: 10).wait(raise_error: false)
151
+ return if cmd.ok?
152
+
153
+ raise AuthenticationError,
154
+ "Authentication failed: #{cmd.error.class} - #{cmd.error.message.inspect}"
155
+ end
156
+
157
+ def ready!
158
+ @mutex.synchronize { @ready = true }
159
+ @ready_cv.broadcast
65
160
  end
66
161
 
67
162
  def on_disconnect(error)
163
+ super
164
+
165
+ @mutex.synchronize { @ready = false }
68
166
  return if @intentionally_closed
69
167
 
70
- super
71
168
  logger.warn "Disconnected from FreeSWITCH ESL: #{error.message}"
72
169
  # Check the flag again in case user code called close during
73
170
  # disconnect handling.
74
171
  attempt_reconnect unless @intentionally_closed
75
172
  end
76
173
 
77
- def attempt_reconnect
174
+ def attempt_reconnect(sync: false)
175
+ # If sync is true, the reconnect attempt is made in the current thread and blocks until it succeeds or fails.
176
+ return reconnect_with_backoff if sync
177
+
178
+ # If a reconnect thread is already running, do not start another one.
179
+ return if @reconnect_thread&.alive?
180
+
181
+ # Start a new thread to handle reconnect attempts in the background.
78
182
  @reconnect_thread = Thread.new do
79
183
  reconnect_with_backoff
80
184
  end
@@ -86,17 +190,14 @@ module Freeswitch
86
190
  delay = config.freeswitch.retry_delay
87
191
 
88
192
  loop do
89
- # Wait before each retry to avoid a tight retry loop.
90
- sleep(delay)
91
193
  break if @intentionally_closed
92
-
93
194
  break if reconnect_once
94
195
 
196
+ # Wait before each retry to avoid a tight retry loop.
197
+ sleep(delay)
198
+
95
199
  retries += 1
96
200
  break if stop_reconnect?(retries)
97
-
98
- # Increase delay after each failure, but keep it under max_retry_delay.
99
- delay = [delay * 2, config.freeswitch.max_retry_delay].min
100
201
  end
101
202
  end
102
203
 
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Freeswitch
4
+ module ESL
5
+ # Represents the asynchronous execution of a bgapi command.
6
+ #
7
+ # A Command is created by {Client#exec} and tracks the full lifecycle of the
8
+ # request: pending → completed (success or failure).
9
+ #
10
+ # === Async usage (non-blocking)
11
+ #
12
+ # cmd = client.exec("originate", "sofia/default/1234@example.com")
13
+ # cmd.on_complete { |c| puts c.response.body }
14
+ # # ... do other work ...
15
+ # cmd.response # blocks until completed (or timeout)
16
+ #
17
+ # === Sync usage (blocking)
18
+ #
19
+ # cmd = client.exec("originate", "sofia/default/1234@example.com")
20
+ # cmd.wait
21
+ # puts cmd.response.body
22
+ #
23
+ # === Timeout
24
+ #
25
+ # The default timeout is {DEFAULT_TIMEOUT} seconds. If the bgapi result event
26
+ # does not arrive in time the command transitions to :timeout and any
27
+ # subsequent result is silently ignored.
28
+ class Command
29
+ DEFAULT_TIMEOUT = 60
30
+
31
+ # @return [:pending, :ok, :failed, :timeout] current status of the command
32
+ attr_reader :status
33
+
34
+ # @return [String, nil] Job-UUID assigned by FreeSWITCH (nil if invalid)
35
+ attr_reader :job_uuid
36
+
37
+ # @return [CommandError, TimeoutError, nil] the error that caused the command to fail (nil if successful)
38
+ attr_reader :error
39
+
40
+ attr_reader :command, :args, :sent_at, :finished_at
41
+
42
+ def initialize(client, command, *args, timeout: DEFAULT_TIMEOUT, raise_error: true, &block)
43
+ @client = client
44
+ @command = command
45
+ @args = args
46
+ @timeout = timeout
47
+ @raise_error = raise_error
48
+ @status = :pending
49
+ @job_uuid = nil
50
+ @response = nil
51
+ @error = nil
52
+ @callbacks = block ? [block] : []
53
+ @mutex = Mutex.new
54
+ @cond = ConditionVariable.new
55
+ @sent_at = nil
56
+ @finished_at = nil
57
+
58
+ validate_command!
59
+ end
60
+
61
+ def execute!
62
+ configure_timeout!
63
+ return unless wait_until_client_ready
64
+
65
+ @sent_at = now
66
+ @status = :sent
67
+ @job_uuid = @client.bgapi(@command, *@args) do |event|
68
+ event.error? ? fail!(event) : complete!(event)
69
+ end
70
+ rescue StandardError => e
71
+ @status = :failed
72
+ @error = e
73
+ @finished_at = now
74
+ raise e if @raise_error
75
+ end
76
+
77
+ # Register a callback to be called when the command completes (success or
78
+ # failure). Multiple callbacks are supported and are called in order.
79
+ # If the command is already completed the block is called immediately.
80
+ # Returns +self+ for chaining.
81
+ def on_complete(&block)
82
+ raise ArgumentError, "Block is required" unless block_given?
83
+
84
+ @callbacks << block
85
+ block.call(self) if completed?
86
+
87
+ self
88
+ end
89
+
90
+ # Block the calling thread until the command completes or the timeout
91
+ # expires.
92
+ # @return [Protocol::Event] the event returned by FreeSWITCH when the command completed successfully
93
+ # @return [nil] if the command failed or timed out but did not raise an exception (raise_error: false)
94
+ # @raise [CommandError, TimeoutError] when the command failed or timed out
95
+ def response
96
+ wait unless completed?
97
+
98
+ @response
99
+ end
100
+
101
+ # Wait for the command to complete, fail or timeout.
102
+ # @return [Command] self after completion
103
+ def wait
104
+ @mutex.synchronize do
105
+ return self if completed?
106
+
107
+ wait_time = @timeout ? (@timeout - execution_time.to_f) : nil
108
+ @cond.wait(@mutex, wait_time)
109
+ end
110
+
111
+ raise @error if @error && @raise_error
112
+
113
+ self
114
+ end
115
+
116
+ # @return [Boolean] true when the command has finished (success or failure)
117
+ def completed?
118
+ !pending?
119
+ end
120
+
121
+ # @return [Boolean] true when the command is still waiting for a result
122
+ def pending?
123
+ %i[pending sent].include?(@status)
124
+ end
125
+
126
+ # @return [Boolean] true when the command completed successfully
127
+ def ok?
128
+ @status == :ok
129
+ end
130
+
131
+ # @return [Boolean] true when the command terminated with a timeout (a special kind of failure)
132
+ def timeout?
133
+ @status == :timeout
134
+ end
135
+
136
+ # @return [Boolean] true when the command failed or timed out
137
+ def failed?
138
+ %i[failed timeout].include?(@status)
139
+ end
140
+
141
+ def execution_time
142
+ return nil unless @sent_at
143
+
144
+ (@finished_at || now) - @sent_at
145
+ end
146
+
147
+ private
148
+
149
+ def validate_command!
150
+ return unless @command.nil? || @command.strip.empty?
151
+
152
+ @status = :failed
153
+ @error = "Command cannot be blank"
154
+ raise ArgumentError, @error
155
+ end
156
+
157
+ def configure_timeout!
158
+ return unless @timeout
159
+
160
+ Ztimer.after(@timeout * 1000) do
161
+ timeout!("Command timed out after #{@timeout}s waiting for BACKGROUND_JOB result")
162
+ end
163
+ end
164
+
165
+ # Wait until the client is ready to send commands. If readiness does not
166
+ # happen within the configured timeout, the command fails before any bgapi
167
+ # request is sent to FreeSWITCH.
168
+ def wait_until_client_ready
169
+ is_ready = @client.wait_until_ready(timeout: @timeout, raise_error: false).ready?
170
+ return is_ready if is_ready
171
+
172
+ settle(:failed) do
173
+ @error = ConnectionError.new("FreeSWITCH ESL Client is not ready to send commands")
174
+ end
175
+
176
+ false
177
+ end
178
+
179
+ # Called when the BACKGROUND_JOB event reports an ESL-level failure body.
180
+ # @param event [Protocol::Event] the terminal event containing the error details.
181
+ def fail!(event)
182
+ settle(:failed) do
183
+ @response = event
184
+ @error = CommandError.new("Command `#{@command}#{@args.map { |arg| " #{arg}" }.join}` failed: #{event.body}")
185
+ end
186
+ end
187
+
188
+ # Called internally by {EventDispatcher} when the BACKGROUND_JOB event
189
+ # arrives. Ignored if the command is no longer pending (e.g. already
190
+ # timed out).
191
+ # @param event [Protocol::Event]
192
+ def complete!(event)
193
+ settle(:ok) { @response = event }
194
+ end
195
+
196
+ def timeout!(message)
197
+ settle(:timeout) { @error = TimeoutError.new(message) }
198
+ end
199
+
200
+ # Apply a terminal state transition under the mutex, wake waiters and run
201
+ # completion callbacks after the internal state is visible to observers.
202
+ def settle(status)
203
+ @mutex.synchronize do
204
+ return unless pending? # do nothing if already completed
205
+
206
+ yield
207
+ @status = status.to_sym
208
+ @finished_at = now
209
+ @sent_at ||= @finished_at # if the command never sent, set sent_at to finished_at
210
+ @cond.broadcast
211
+ end
212
+
213
+ @callbacks.each { |cb| cb.call(self) }
214
+ end
215
+
216
+ def now
217
+ Time.now
218
+ end
219
+ end
220
+ end
221
+ end