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.
@@ -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
- DEFAULTS = {
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(DEFAULTS[:freeswitch].dup.merge(options[:freeswitch] || {}))
24
- config.logger = options[:logger] || DEFAULTS[: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&.close
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