freeswitch-esl 0.1.0 → 0.2.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 +4 -4
- data/README.md +83 -22
- data/lib/freeswitch/esl/client.rb +120 -19
- data/lib/freeswitch/esl/command.rb +221 -0
- data/lib/freeswitch/esl/configuration.rb +39 -13
- data/lib/freeswitch/esl/connection/command_dispatcher.rb +258 -0
- data/lib/freeswitch/esl/connection/command_request.rb +154 -0
- data/lib/freeswitch/esl/connection/event_dispatcher.rb +10 -2
- data/lib/freeswitch/esl/connection.rb +53 -75
- data/lib/freeswitch/esl/logger.rb +3 -1
- data/lib/freeswitch/esl/protocol/event.rb +119 -0
- data/lib/freeswitch/esl/protocol/message.rb +7 -2
- data/lib/freeswitch/esl/version.rb +1 -1
- data/lib/freeswitch/esl.rb +3 -1
- metadata +61 -3
- data/lib/freeswitch/esl/connection/message_reader.rb +0 -109
|
@@ -1,30 +1,56 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "configatron"
|
|
4
|
+
require "forwardable"
|
|
4
5
|
|
|
5
6
|
module Freeswitch
|
|
6
7
|
module ESL
|
|
7
8
|
class Configuration
|
|
8
|
-
|
|
9
|
-
freeswitch: {
|
|
10
|
-
host: ENV.fetch("FREESWITCH_ESL_HOST", "127.0.0.1"),
|
|
11
|
-
port: ENV.fetch("FREESWITCH_ESL_PORT", 8021).to_i,
|
|
12
|
-
password: ENV.fetch("FREESWITCH_ESL_PASSWORD", "ClueCon"),
|
|
13
|
-
timeout: ENV.fetch("FREESWITCH_ESL_TIMEOUT", 5).to_i,
|
|
14
|
-
retry_delay: ENV.fetch("FREESWITCH_ESL_RETRY_DELAY", 1.0).to_f,
|
|
15
|
-
max_retries: Float::INFINITY
|
|
16
|
-
},
|
|
17
|
-
logger: Freeswitch::ESL::Logger.default_logger
|
|
18
|
-
}.freeze
|
|
9
|
+
extend Forwardable
|
|
19
10
|
|
|
20
11
|
class << self
|
|
12
|
+
def defaults
|
|
13
|
+
@defaults ||= {
|
|
14
|
+
freeswitch: {
|
|
15
|
+
host: ENV.fetch("FREESWITCH_ESL_HOST", "127.0.0.1"),
|
|
16
|
+
port: ENV.fetch("FREESWITCH_ESL_PORT", 8021).to_i,
|
|
17
|
+
password: ENV.fetch("FREESWITCH_ESL_PASSWORD", nil),
|
|
18
|
+
reconnect: %w[true 1 yes].include?(ENV.fetch("FREESWITCH_ESL_RECONNECT", "true").downcase),
|
|
19
|
+
retry_delay: ENV.fetch("FREESWITCH_ESL_RETRY_DELAY", 1.0).to_f,
|
|
20
|
+
max_retries: Float::INFINITY
|
|
21
|
+
},
|
|
22
|
+
logger: Freeswitch::ESL::Logger.default_logger,
|
|
23
|
+
debug: false
|
|
24
|
+
}.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
def build(**options)
|
|
22
28
|
config = Configatron::RootStore.new
|
|
23
|
-
config.freeswitch.configure_from_hash(
|
|
24
|
-
config.logger = options[:logger] ||
|
|
29
|
+
config.freeswitch.configure_from_hash(defaults[:freeswitch].dup.merge(options[:freeswitch] || {}))
|
|
30
|
+
config.logger = options[:logger] || defaults[:logger]
|
|
31
|
+
config.debug = options[:debug] || defaults[:debug]
|
|
25
32
|
config
|
|
26
33
|
end
|
|
27
34
|
end
|
|
35
|
+
|
|
36
|
+
def_delegators :@config, :freeswitch, :logger, :debug, :to_h
|
|
37
|
+
def_delegators :@config, :logger=, :debug=
|
|
38
|
+
|
|
39
|
+
def initialize(**)
|
|
40
|
+
@config = self.class.build(**)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def update(**options)
|
|
44
|
+
%i[freeswitch logger debug].each do |key|
|
|
45
|
+
next unless options.key?(key)
|
|
46
|
+
|
|
47
|
+
if key == :freeswitch
|
|
48
|
+
@config.freeswitch.configure_from_hash(options[:freeswitch])
|
|
49
|
+
else
|
|
50
|
+
@config[key] = options[key] || @config[key]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
28
54
|
end
|
|
29
55
|
end
|
|
30
56
|
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Freeswitch
|
|
6
|
+
module ESL
|
|
7
|
+
class Connection
|
|
8
|
+
# Coordinates command execution: manages socket write, command queueing,
|
|
9
|
+
# and response routing.
|
|
10
|
+
#
|
|
11
|
+
# Threading model:
|
|
12
|
+
# * A *reader thread* reads messages from the socket and delivers them
|
|
13
|
+
# to waiting {CommandRequest} objects via FIFO queue ordering.
|
|
14
|
+
# * Command writes and enqueues are atomic under a single mutex so that
|
|
15
|
+
# command order on wire matches queue order.
|
|
16
|
+
# * Responses are matched to commands by simple FIFO order.
|
|
17
|
+
# * Events (text/event-json) are forwarded to {EventDispatcher}.
|
|
18
|
+
# * Server-initiated messages (auth/request) invoke callbacks.
|
|
19
|
+
#
|
|
20
|
+
class CommandDispatcher
|
|
21
|
+
include Freeswitch::ESL::Logger
|
|
22
|
+
|
|
23
|
+
MSG_TERMINATOR = "\n\n"
|
|
24
|
+
|
|
25
|
+
def initialize(socket, event_dispatcher, debug: false)
|
|
26
|
+
@socket = socket
|
|
27
|
+
@event_dispatcher = event_dispatcher
|
|
28
|
+
@write_mutex = Mutex.new
|
|
29
|
+
@cond = ConditionVariable.new
|
|
30
|
+
@queue = Queue.new
|
|
31
|
+
@on_disconnect = nil
|
|
32
|
+
@reader_thread = nil
|
|
33
|
+
@closed = false
|
|
34
|
+
@auth_request_received = false
|
|
35
|
+
@debug = debug
|
|
36
|
+
@disconnect_error = nil
|
|
37
|
+
@read_buffer = String.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if the dispatcher is closed.
|
|
41
|
+
def closed?
|
|
42
|
+
@closed
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Register a callback invoked after the dispatcher transitions to the
|
|
46
|
+
# disconnected state and pending commands have been notified.
|
|
47
|
+
def on_disconnect(&block)
|
|
48
|
+
@on_disconnect = block
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Start the socket reader thread and the periodic socket health check.
|
|
53
|
+
# Safe to call more than once: subsequent calls are ignored while the
|
|
54
|
+
# current reader thread reference is still present.
|
|
55
|
+
def start
|
|
56
|
+
return if @reader_thread
|
|
57
|
+
|
|
58
|
+
@reader_thread = Thread.new do
|
|
59
|
+
loop do
|
|
60
|
+
break if closed?
|
|
61
|
+
|
|
62
|
+
msg = read_message
|
|
63
|
+
unless msg
|
|
64
|
+
disconnect!(DisconnectedError.new("Connection closed by remote host"))
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
route_message(msg)
|
|
69
|
+
rescue IOError, Errno::ECONNRESET, Errno::ENOTCONN => e
|
|
70
|
+
disconnect!(DisconnectedError.new(e.message))
|
|
71
|
+
break
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
@reader_thread.name = "esl-reader"
|
|
75
|
+
@reader_thread.abort_on_exception = false
|
|
76
|
+
@healthcheck_timer = Ztimer.every(1000) { check_socket_health }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Wait for the server-initiated +auth/request+ message from FreeSWITCH.
|
|
80
|
+
# Raises {TimeoutError} if the message does not arrive within +timeout+
|
|
81
|
+
# seconds, or re-raises the disconnect error if the socket closes first.
|
|
82
|
+
def wait_for_auth_request(timeout: nil)
|
|
83
|
+
@write_mutex.synchronize do
|
|
84
|
+
return if @auth_request_received
|
|
85
|
+
|
|
86
|
+
@cond.wait(@write_mutex, timeout)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
raise @disconnect_error if @closed && @disconnect_error
|
|
90
|
+
raise TimeoutError, "Timed out waiting for auth/request" unless @auth_request_received
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Stop the dispatcher, cancel the healthcheck timer and broadcast to
|
|
94
|
+
# any waiters blocked on +wait_for_auth_request+.
|
|
95
|
+
#
|
|
96
|
+
# Socket close failures during teardown are ignored because shutdown is
|
|
97
|
+
# already in progress and there is no recovery path.
|
|
98
|
+
def stop
|
|
99
|
+
return if closed?
|
|
100
|
+
|
|
101
|
+
logger.debug "Stopping command dispatcher and closing socket"
|
|
102
|
+
@closed = true
|
|
103
|
+
@healthcheck_timer&.cancel!
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
@socket.close
|
|
107
|
+
rescue StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
@reader_thread&.join(0.1) if @reader_thread != Thread.current
|
|
111
|
+
@queue.close
|
|
112
|
+
@cond.broadcast # unblock wait_for_auth_request if still waiting
|
|
113
|
+
logger.debug "Command dispatcher stopped"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Send a raw command on ESL socket and return CommandRequest.
|
|
117
|
+
#
|
|
118
|
+
# @param command [String] the ESL command to send
|
|
119
|
+
# @param timeout [Numeric, nil] seconds to wait for response
|
|
120
|
+
# @return [CommandRequest] the command request object
|
|
121
|
+
# @raise [TimeoutError] when the response does not arrive in time
|
|
122
|
+
# @raise [DisconnectedError] when the connection is closed
|
|
123
|
+
def execute_command(command, timeout: nil)
|
|
124
|
+
raise DisconnectedError, "Connection is closed" if @closed
|
|
125
|
+
|
|
126
|
+
cmd = CommandRequest.new(command, timeout)
|
|
127
|
+
@write_mutex.synchronize { execute(cmd) }
|
|
128
|
+
|
|
129
|
+
cmd
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Return the number of commands currently waiting for a response.
|
|
133
|
+
def pending_commands_count
|
|
134
|
+
@queue.size
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Transition the dispatcher to the disconnected state, fail every
|
|
140
|
+
# pending command with the same error and then invoke the disconnect
|
|
141
|
+
# callback once teardown has completed.
|
|
142
|
+
def disconnect!(error)
|
|
143
|
+
@disconnect_error = error
|
|
144
|
+
logger.debug "Disconnected: #{error.message}"
|
|
145
|
+
|
|
146
|
+
# Deliver DisconnectedError to all pending in-flight command executions
|
|
147
|
+
loop do
|
|
148
|
+
cmd = pop_command(non_block: true)
|
|
149
|
+
break unless cmd
|
|
150
|
+
|
|
151
|
+
cmd.disconnected!(error)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
stop
|
|
155
|
+
@on_disconnect&.call(error)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Enqueue and write the command while the caller holds +@write_mutex+ so
|
|
159
|
+
# queue order always matches socket write order.
|
|
160
|
+
def execute(cmd)
|
|
161
|
+
@queue << cmd
|
|
162
|
+
@socket.write("#{cmd.command}#{MSG_TERMINATOR}")
|
|
163
|
+
logger.debug "[SENT] #{cmd.command}" if @debug
|
|
164
|
+
cmd.sent!
|
|
165
|
+
rescue IOError, Errno::ECONNRESET, Errno::ENOTCONN => e
|
|
166
|
+
disconnect!(DisconnectedError.new(e.message))
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
cmd.process_error(e)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Read one ESL message from the socket.
|
|
172
|
+
# Returns +nil+ when the stream reaches EOF or header parsing cannot
|
|
173
|
+
# produce a message.
|
|
174
|
+
def read_message
|
|
175
|
+
headers = read_message_headers
|
|
176
|
+
return nil if headers.nil? || headers.empty?
|
|
177
|
+
|
|
178
|
+
body = @socket.read(headers["Content-Length"].to_i) if headers.key?("Content-Length")
|
|
179
|
+
logger.debug "[RECEIVED] #{body}" if body && @debug
|
|
180
|
+
Protocol::Message.new(headers, body)
|
|
181
|
+
rescue JSON::ParserError => e
|
|
182
|
+
logger.error "Failed to parse JSON body: #{e.message}"
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Read ESL headers until the blank-line terminator.
|
|
187
|
+
# Returns +nil+ on EOF or after the dispatcher has already been closed.
|
|
188
|
+
def read_message_headers
|
|
189
|
+
headers = {}
|
|
190
|
+
|
|
191
|
+
loop do
|
|
192
|
+
line = @socket.gets("\n")
|
|
193
|
+
return nil if closed? || line.nil?
|
|
194
|
+
|
|
195
|
+
line = line.chomp
|
|
196
|
+
break if line.empty?
|
|
197
|
+
|
|
198
|
+
key, value = line.split(": ", 2)
|
|
199
|
+
headers[key] = value
|
|
200
|
+
logger.debug "[RECEIVED] #{line}" if @debug
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
headers
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Process a reply message from the server.
|
|
207
|
+
def process_reply(message)
|
|
208
|
+
# Use non-blocking pop of the next command waiting for a response
|
|
209
|
+
cmd = pop_command(non_block: true)
|
|
210
|
+
if cmd
|
|
211
|
+
cmd.process_reply(message)
|
|
212
|
+
else
|
|
213
|
+
logger.warn "Received unexpected command reply: #{message.headers.inspect}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Route a protocol message to the auth bootstrap path, the pending
|
|
218
|
+
# command queue, the event dispatcher or the disconnect flow.
|
|
219
|
+
def route_message(message)
|
|
220
|
+
case message.content_type
|
|
221
|
+
when "auth/request"
|
|
222
|
+
# Server-initiated auth request
|
|
223
|
+
@write_mutex.synchronize do
|
|
224
|
+
@auth_request_received = true
|
|
225
|
+
@cond.broadcast
|
|
226
|
+
end
|
|
227
|
+
when "command/reply", "api/response"
|
|
228
|
+
# Response to a command we sent
|
|
229
|
+
process_reply(message)
|
|
230
|
+
|
|
231
|
+
when "text/event-json"
|
|
232
|
+
# Forward event to dispatcher
|
|
233
|
+
@event_dispatcher.enqueue_event(Protocol::Event.new(message.body))
|
|
234
|
+
|
|
235
|
+
when "text/disconnect-notice"
|
|
236
|
+
disconnect!(DisconnectedError.new("FreeSWITCH sent disconnect notice"))
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def pop_command(non_block: true)
|
|
241
|
+
@queue.pop(non_block)
|
|
242
|
+
rescue StandardError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Periodically attempt a zero-byte write to detect half-open sockets in
|
|
247
|
+
# the background, without waiting for the next explicit command write.
|
|
248
|
+
def check_socket_health
|
|
249
|
+
@write_mutex.synchronize do
|
|
250
|
+
@socket.write_nonblock("", exception: false)
|
|
251
|
+
end
|
|
252
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN => e
|
|
253
|
+
disconnect!(DisconnectedError.new("Socket health check failed: #{e.message}"))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Freeswitch
|
|
4
|
+
module ESL
|
|
5
|
+
class Connection
|
|
6
|
+
# Represents a pending command execution waiting for a response.
|
|
7
|
+
#
|
|
8
|
+
# Created before sending a command to the socket; the response is
|
|
9
|
+
# delivered asynchronously by the command dispatcher via {#process_reply},
|
|
10
|
+
# {#process_error} or {#disconnected!}.
|
|
11
|
+
#
|
|
12
|
+
# Handles timeout: if the caller exceeds the deadline, {#wait} raises
|
|
13
|
+
# {TimeoutError} and marks the execution as expired. A late-arriving
|
|
14
|
+
# response is silently discarded (callback not invoked), keeping the
|
|
15
|
+
# command queue in sync with the wire protocol.
|
|
16
|
+
class CommandRequest
|
|
17
|
+
attr_reader :command, :status, :created_at, :sent_at, :finished_at
|
|
18
|
+
|
|
19
|
+
def initialize(command, timeout = nil)
|
|
20
|
+
@command = command
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@condition = ConditionVariable.new
|
|
23
|
+
@response = nil
|
|
24
|
+
@status = :queued
|
|
25
|
+
@created_at = Time.now
|
|
26
|
+
@sent_at = nil
|
|
27
|
+
@finished_at = nil
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ok?
|
|
32
|
+
@status == :completed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sent!
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
return if terminated?
|
|
38
|
+
|
|
39
|
+
@status = :sent
|
|
40
|
+
@sent_at = Time.now
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Called by reader thread to deliver the response message or exception.
|
|
45
|
+
# No-op if the execution has already expired due to timeout.
|
|
46
|
+
def process_reply(reply)
|
|
47
|
+
return if terminated?
|
|
48
|
+
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
@response = reply
|
|
51
|
+
@status = reply.successful? ? :completed : :failed
|
|
52
|
+
@error = reply.successful? ? nil : CommandError.new(failed_reply_error_message(reply))
|
|
53
|
+
@condition.signal
|
|
54
|
+
@finished_at = Time.now
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def process_error(error)
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
return if terminated?
|
|
61
|
+
|
|
62
|
+
@error = error
|
|
63
|
+
@status = :failed
|
|
64
|
+
@finished_at = Time.now
|
|
65
|
+
@condition.signal
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def disconnected!(error)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
return if terminated? # already completed/failed/timeout
|
|
72
|
+
|
|
73
|
+
@error = error
|
|
74
|
+
@status = :disconnected
|
|
75
|
+
@finished_at = Time.now
|
|
76
|
+
@condition.signal
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Return the terminal reply, blocking without raising so callers can
|
|
81
|
+
# inspect failures through {#error} instead of exceptions.
|
|
82
|
+
def response
|
|
83
|
+
wait(raise_error: false) unless terminated?
|
|
84
|
+
|
|
85
|
+
@response
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Return the terminal error, blocking without raising so callers can
|
|
89
|
+
# branch on the request outcome after completion.
|
|
90
|
+
def error
|
|
91
|
+
wait(raise_error: false) unless terminated?
|
|
92
|
+
|
|
93
|
+
@error
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Block the calling thread until a response arrives or the deadline passes.
|
|
97
|
+
#
|
|
98
|
+
# @return [Protocol::Message] the response message
|
|
99
|
+
# @raise [TimeoutError] when the deadline passes without a response
|
|
100
|
+
# @raise [DisconnectedError] when the socket is closed
|
|
101
|
+
# @raise [StandardError] any error previously delivered through
|
|
102
|
+
# {#process_error} or a failed reply when +raise_error+ is true.
|
|
103
|
+
def wait(raise_error: true)
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
raise_error_if_necessary(raise_error:)
|
|
106
|
+
return self if terminated?
|
|
107
|
+
|
|
108
|
+
@condition.wait(@mutex, @timeout)
|
|
109
|
+
|
|
110
|
+
# If still pending after the wait window, mark as timed-out and raise.
|
|
111
|
+
if %i[queued sent].include?(@status)
|
|
112
|
+
@status = :timeout
|
|
113
|
+
@error = TimeoutError.new("Command timed out after #{@timeout}s waiting for response")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise_error_if_necessary(raise_error:)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def terminated?
|
|
125
|
+
%i[
|
|
126
|
+
completed
|
|
127
|
+
failed
|
|
128
|
+
timeout
|
|
129
|
+
unauthorized
|
|
130
|
+
disconnected
|
|
131
|
+
].include?(@status)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def failed_reply_error_message(reply)
|
|
135
|
+
if reply.content_type == "text/disconnect-notice"
|
|
136
|
+
"Disconnected: #{reply.body || reply.reply_text || 'No details'}"
|
|
137
|
+
else
|
|
138
|
+
filtered_command = @command.gsub(/auth\s+\S+/, "auth <REDACTED>")
|
|
139
|
+
|
|
140
|
+
<<~ERROR_MESSAGE.chomp
|
|
141
|
+
Command `#{filtered_command}` failed: (#{reply.content_type}) #{reply.body || reply.reply_text || 'Unknown error'}
|
|
142
|
+
ERROR_MESSAGE
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Re-raise the terminal error when the request has already completed in
|
|
147
|
+
# a failing state and the caller asked for exception-based handling.
|
|
148
|
+
def raise_error_if_necessary(raise_error: true)
|
|
149
|
+
raise @error if @error && raise_error && terminated?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -32,13 +32,16 @@ module Freeswitch
|
|
|
32
32
|
|
|
33
33
|
# Register a handler for a background job result (by job UUID).
|
|
34
34
|
def register_bgapi_handler(job_uuid, block)
|
|
35
|
+
return if job_uuid.nil? || block.nil?
|
|
36
|
+
|
|
35
37
|
@bgapi_mutex.synchronize { @bgapi_handlers[job_uuid] = block }
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Start the dispatcher thread.
|
|
39
41
|
def start
|
|
40
|
-
return if @dispatcher_thread
|
|
42
|
+
return if @dispatcher_thread&.alive?
|
|
41
43
|
|
|
44
|
+
@event_queue = Queue.new if @event_queue.closed? # reopen if previously closed
|
|
42
45
|
@dispatcher_thread = Thread.new do
|
|
43
46
|
loop do
|
|
44
47
|
event = @event_queue.pop
|
|
@@ -53,8 +56,13 @@ module Freeswitch
|
|
|
53
56
|
|
|
54
57
|
# Stop the dispatcher thread (close queue and join).
|
|
55
58
|
def stop
|
|
56
|
-
@event_queue
|
|
59
|
+
@event_queue.close
|
|
57
60
|
@dispatcher_thread&.join
|
|
61
|
+
@dispatcher_thread = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def pending_bgapi_command_uuids
|
|
65
|
+
@bgapi_mutex.synchronize { @bgapi_handlers.keys }
|
|
58
66
|
end
|
|
59
67
|
|
|
60
68
|
private
|