switest 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 53955d7e1a8c6b69c4a75f8f7ec87d7de55d99a515ef986e19f3645e0acf5dd5
4
+ data.tar.gz: 5375ca5a09fa90c44250264401a91cf37584d47af51222d2f2d19c2fe0f85cff
5
+ SHA512:
6
+ metadata.gz: 8281a8651ebb7dc43d2776fd061b46b445f23872b6bdb48cd3eca55902fec82df6282d06343c95a182d7dd99dd614f52cbfa5e56c185f90bf3d008f017c7ba18
7
+ data.tar.gz: 1e00e657bcf9344e4a623d3314c9a94acccec6677c9ca5fb491e1a8a2c87a56bf2c332f5a416216eddff1839d6ba4804b25ddf8505a5234ae6ce94d996fdd518
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Relatel A/S
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,311 @@
1
+ # Switest
2
+
3
+ Functional testing for voice applications via FreeSWITCH ESL.
4
+
5
+ Switest lets you write tests for your voice applications using direct
6
+ ESL (Event Socket Library) communication with FreeSWITCH. Tests run as
7
+ plain Minitest cases — no Adhearsion, no Rayo, just a TCP socket to
8
+ FreeSWITCH.
9
+
10
+ ## Table of Contents
11
+
12
+ - [Installation](#installation)
13
+ - [Quick Start](#quick-start)
14
+ - [Core Concepts](#core-concepts)
15
+ - [Scenario](#scenario)
16
+ - [Agent](#agent)
17
+ - [API Reference](#api-reference)
18
+ - [Agent Class Methods](#agent-class-methods)
19
+ - [Agent Instance Methods](#agent-instance-methods)
20
+ - [Scenario Assertions](#scenario-assertions)
21
+ - [Dial Options](#dial-options)
22
+ - [Guards](#guards)
23
+ - [DTMF](#dtmf)
24
+ - [Docker / FreeSWITCH Setup](#docker--freeswitch-setup)
25
+ - [Configuration](#configuration)
26
+ - [Dependencies](#dependencies)
27
+ - [License](#license)
28
+
29
+ ## Installation
30
+
31
+ Add to your Gemfile:
32
+
33
+ ```ruby
34
+ gem "switest"
35
+ ```
36
+
37
+ Then run `bundle install`.
38
+
39
+ ## Quick Start
40
+
41
+ ```ruby
42
+ require "minitest"
43
+ require "switest"
44
+
45
+ class MyScenario < Switest::Scenario
46
+ def test_outbound_call
47
+ alice = Agent.dial("sofia/gateway/provider/+4512345678")
48
+ assert alice.wait_for_answer(timeout: 10), "Call should be answered"
49
+
50
+ alice.hangup
51
+ assert alice.ended?, "Call should be ended"
52
+ end
53
+ end
54
+ ```
55
+
56
+ Run with Minitest's rake task:
57
+
58
+ ```ruby
59
+ # Rakefile
60
+ require "minitest/test_task"
61
+
62
+ Minitest::TestTask.create(:test) do |t|
63
+ t.libs << "lib" << "test"
64
+ t.test_globs = ["test/**/*_test.rb"]
65
+ end
66
+ ```
67
+
68
+ ```bash
69
+ bundle exec rake test
70
+ ```
71
+
72
+ ## Core Concepts
73
+
74
+ ### Scenario
75
+
76
+ `Switest::Scenario` is a Minitest::Test subclass that handles FreeSWITCH
77
+ connection lifecycle for you. Each test method gets a fresh ESL client that
78
+ connects on setup and disconnects on teardown.
79
+
80
+ ```ruby
81
+ class MyTest < Switest::Scenario
82
+ def test_something
83
+ # Agent, assert_call, hangup_all, etc. are available here
84
+ end
85
+ end
86
+ ```
87
+
88
+ ### Agent
89
+
90
+ An **Agent** represents a party in a call. There are two kinds:
91
+
92
+ **Outbound** — initiates a call:
93
+
94
+ ```ruby
95
+ alice = Agent.dial("sofia/gateway/provider/+4512345678")
96
+ alice.wait_for_answer(timeout: 10)
97
+ alice.hangup
98
+ ```
99
+
100
+ **Inbound** — listens for an incoming call matching a guard:
101
+
102
+ ```ruby
103
+ bob = Agent.listen_for_call(to: /^1000/)
104
+ # ... something triggers an inbound call to 1000 ...
105
+ bob.wait_for_call(timeout: 5)
106
+ bob.answer
107
+ ```
108
+
109
+ #### wait_for_answer vs answer
110
+
111
+ | Method | Direction | What it does |
112
+ |-------------------------------|-------------|---------------------------------------------|
113
+ | `wait_for_answer(timeout:)` | Outbound | Passively waits for the remote to answer |
114
+ | `answer(wait:)` | Inbound | Actively answers the call |
115
+
116
+ #### wait_for_end vs hangup
117
+
118
+ | Method | Use case | What it does |
119
+ |--------------------------|-----------------------|--------------------------------------|
120
+ | `wait_for_end(timeout:)` | Remote hangs up | Passively waits for the call to end |
121
+ | `hangup(wait:)` | You hang up | Sends hangup and waits |
122
+
123
+ ## API Reference
124
+
125
+ ### Agent Class Methods
126
+
127
+ ```ruby
128
+ Agent.dial(destination, from: nil, timeout: nil, headers: {})
129
+ Agent.listen_for_call(guards) # e.g. to: /pattern/, from: /pattern/
130
+ ```
131
+
132
+ ### Agent Instance Methods
133
+
134
+ #### Actions
135
+
136
+ ```ruby
137
+ agent.answer(wait: 5) # Answer an inbound call
138
+ agent.hangup(wait: 5) # Hang up
139
+ agent.reject(reason = :decline) # Reject inbound call (:decline or :busy)
140
+ agent.send_dtmf(digits) # Send DTMF tones
141
+ agent.receive_dtmf(count:, timeout:) # Receive DTMF digits
142
+ ```
143
+
144
+ #### Waits
145
+
146
+ ```ruby
147
+ agent.wait_for_call(timeout: 5) # Wait for inbound call to arrive
148
+ agent.wait_for_answer(timeout: 5) # Wait for call to be answered
149
+ agent.wait_for_bridge(timeout: 5) # Wait for call to be bridged
150
+ agent.wait_for_end(timeout: 5) # Wait for call to end
151
+ ```
152
+
153
+ #### State
154
+
155
+ ```ruby
156
+ agent.call? # Has a call object?
157
+ agent.alive? # Call exists and not ended?
158
+ agent.active? # Answered and not ended?
159
+ agent.answered? # Has been answered?
160
+ agent.ended? # Has ended?
161
+ agent.start_time # When call started
162
+ agent.answer_time # When answered
163
+ agent.end_reason # e.g. "NORMAL_CLEARING"
164
+ ```
165
+
166
+ ### Scenario Assertions
167
+
168
+ `Switest::Scenario` provides these assertions:
169
+
170
+ ```ruby
171
+ assert_call(agent, timeout: 5) # Agent receives a call
172
+ assert_no_call(agent, timeout: 2) # Agent does NOT receive a call
173
+ assert_hungup(agent, timeout: 5) # Call has ended
174
+ assert_not_hungup(agent, timeout: 2) # Call is still active
175
+ assert_dtmf(agent, "123", timeout: 5) # Agent receives expected DTMF digits
176
+ ```
177
+
178
+ The `hangup_all` helper ends all active calls (useful before CDR assertions):
179
+
180
+ ```ruby
181
+ hangup_all(cause: "NORMAL_CLEARING", timeout: 5)
182
+ ```
183
+
184
+ ### Dial Options
185
+
186
+ ```ruby
187
+ Agent.dial(
188
+ "sofia/gateway/provider/+4512345678",
189
+ from: "+4587654321", # Caller ID (number and name)
190
+ timeout: 30, # Originate timeout in seconds
191
+ headers: { "Privacy" => "user;id" } # Custom SIP headers (auto-prefixed sip_h_)
192
+ )
193
+ ```
194
+
195
+ The `from:` parameter accepts several formats:
196
+
197
+ | Format | Effect |
198
+ |-------------------------------------------|------------------------------------------|
199
+ | `"+4512345678"` | Sets caller ID number and name |
200
+ | `"tel:+4512345678"` | Same, strips `tel:` prefix |
201
+ | `"sip:user@host"` | Sets `sip_from_uri` |
202
+ | `"Display Name sip:user@host"` | Sets display name + SIP URI |
203
+ | `'"Display Name" <sip:user@host>'` | Quoted display name + angle-bracketed URI|
204
+
205
+ ### Guards
206
+
207
+ Guards filter which inbound calls match `listen_for_call`:
208
+
209
+ ```ruby
210
+ Agent.listen_for_call(to: /^1000/) # Regex on destination
211
+ Agent.listen_for_call(from: /^\+45/) # Regex on caller ID
212
+ Agent.listen_for_call(to: "1000") # Exact match
213
+ Agent.listen_for_call(to: /^1000/, from: /^\+45/) # Multiple (AND logic)
214
+ ```
215
+
216
+ ## DTMF
217
+
218
+ Send DTMF tones on an active call:
219
+
220
+ ```ruby
221
+ alice.send_dtmf("123#")
222
+ ```
223
+
224
+ Receive DTMF from the remote party:
225
+
226
+ ```ruby
227
+ digits = alice.receive_dtmf(count: 4, timeout: 5)
228
+ assert_equal "1234", digits
229
+ ```
230
+
231
+ Or use the assertion helper:
232
+
233
+ ```ruby
234
+ assert_dtmf(alice, "1234", timeout: 5)
235
+ ```
236
+
237
+ DTMF events are routed per-call — concurrent calls each receive only their
238
+ own digits.
239
+
240
+ ## Docker / FreeSWITCH Setup
241
+
242
+ The project includes a `compose.yml` for running FreeSWITCH locally:
243
+
244
+ ```bash
245
+ docker compose up -d freeswitch # start FreeSWITCH
246
+ docker compose run --rm test # run integration tests
247
+ ```
248
+
249
+ The compose file mounts three config files into FreeSWITCH:
250
+
251
+ | Local file | Container path |
252
+ |-----------------------------------------------|-----------------------------------------------------------------|
253
+ | `docker/freeswitch/event_socket.conf.xml` | `/etc/freeswitch/autoload_configs/event_socket.conf.xml` |
254
+ | `docker/freeswitch/acl.conf.xml` | `/etc/freeswitch/autoload_configs/acl.conf.xml` |
255
+ | `docker/freeswitch/dialplan.xml` | `/etc/freeswitch/dialplan/public/00_switest.xml` |
256
+
257
+ ### FreeSWITCH Requirements
258
+
259
+ 1. **mod_event_socket** must be loaded (default).
260
+
261
+ 2. `event_socket.conf.xml` must allow connections:
262
+
263
+ ```xml
264
+ <configuration name="event_socket.conf" description="Socket Client">
265
+ <settings>
266
+ <param name="nat-map" value="false"/>
267
+ <param name="listen-ip" value="0.0.0.0"/>
268
+ <param name="listen-port" value="8021"/>
269
+ <param name="password" value="ClueCon"/>
270
+ </settings>
271
+ </configuration>
272
+ ```
273
+
274
+ 3. A dialplan that parks inbound calls so Switest can control them:
275
+
276
+ ```xml
277
+ <extension name="switest-park">
278
+ <condition>
279
+ <action application="park"/>
280
+ </condition>
281
+ </extension>
282
+ ```
283
+
284
+ ## Configuration
285
+
286
+ ```ruby
287
+ Switest.configure do |config|
288
+ config.host = "127.0.0.1" # FreeSWITCH host
289
+ config.port = 8021 # ESL port
290
+ config.password = "ClueCon" # ESL password
291
+ config.default_timeout = 5 # Default timeout for waits
292
+ end
293
+ ```
294
+
295
+ Or via environment variables (used by the integration test helper):
296
+
297
+ ```bash
298
+ FREESWITCH_HOST=127.0.0.1
299
+ FREESWITCH_PORT=8021
300
+ FREESWITCH_PASSWORD=ClueCon
301
+ ```
302
+
303
+ ## Dependencies
304
+
305
+ - Ruby >= 3.0
306
+ - concurrent-ruby ~> 1.2
307
+ - minitest >= 5.5, < 7.0
308
+
309
+ ## License
310
+
311
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Switest
4
+ class Agent
5
+ class << self
6
+ # Shared client for all agents in a test
7
+ attr_accessor :client, :events
8
+
9
+ def setup(client, events)
10
+ @client = client
11
+ @events = events
12
+ end
13
+
14
+ def teardown
15
+ @client = nil
16
+ @events = nil
17
+ end
18
+
19
+ def dial(destination, from: nil, timeout: nil, headers: {})
20
+ raise "Agent.setup not called" unless @client
21
+
22
+ call = @client.dial(to: destination, from: from, timeout: timeout, headers: headers)
23
+ new(call)
24
+ end
25
+
26
+ def listen_for_call(guards = {})
27
+ raise "Agent.setup not called" unless @client
28
+
29
+ agent = new(nil)
30
+
31
+ # Register a one-time handler for matching inbound calls
32
+ @events.once(:offer, guards) do |data|
33
+ agent.instance_variable_set(:@call, data[:call])
34
+ end
35
+
36
+ agent
37
+ end
38
+ end
39
+
40
+ attr_reader :call
41
+
42
+ def initialize(call)
43
+ @call = call
44
+ end
45
+
46
+ def call?
47
+ !@call.nil?
48
+ end
49
+
50
+ def answer(wait: 5)
51
+ raise "No call to answer" unless @call
52
+ @call.answer(wait: wait)
53
+ end
54
+
55
+ def hangup(wait: 5)
56
+ raise "No call to hangup" unless @call
57
+ @call.hangup(wait: wait)
58
+ end
59
+
60
+ def reject(reason = :decline)
61
+ raise "No call to reject" unless @call
62
+ @call.reject(reason)
63
+ end
64
+
65
+ def send_dtmf(digits)
66
+ raise "No call for DTMF" unless @call
67
+ @call.send_dtmf(digits)
68
+ end
69
+
70
+ def receive_dtmf(count: 1, timeout: 5)
71
+ raise "No call for DTMF" unless @call
72
+ @call.receive_dtmf(count: count, timeout: timeout)
73
+ end
74
+
75
+ def wait_for_call(timeout: 5)
76
+ deadline = Time.now + timeout
77
+ while Time.now < deadline
78
+ return true if @call
79
+ sleep 0.1
80
+ end
81
+ false
82
+ end
83
+
84
+ def wait_for_answer(timeout: 5)
85
+ raise "No call to wait for" unless @call
86
+ @call.wait_for_answer(timeout: timeout)
87
+ end
88
+
89
+ def wait_for_bridge(timeout: 5)
90
+ raise "No call to wait for" unless @call
91
+ @call.wait_for_bridge(timeout: timeout)
92
+ end
93
+
94
+ def wait_for_end(timeout: 5)
95
+ raise "No call to wait for" unless @call
96
+ @call.wait_for_end(timeout: timeout)
97
+ end
98
+
99
+ # Delegate state queries to call
100
+ def alive?
101
+ @call&.alive? || false
102
+ end
103
+
104
+ def active?
105
+ @call&.active? || false
106
+ end
107
+
108
+ def answered?
109
+ @call&.answered? || false
110
+ end
111
+
112
+ def ended?
113
+ @call&.ended? || false
114
+ end
115
+
116
+ def start_time
117
+ @call&.start_time
118
+ end
119
+
120
+ def answer_time
121
+ @call&.answer_time
122
+ end
123
+
124
+ def end_reason
125
+ @call&.end_reason
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Switest
4
+ # Hash subclass with case-insensitive key lookup
5
+ class CaseInsensitiveHash < Hash
6
+ def self.from(hash)
7
+ new.merge!(hash)
8
+ end
9
+
10
+ def [](key)
11
+ return super if key?(key)
12
+ key_downcase = key.downcase
13
+ found_key = keys.find { |k| k.downcase == key_downcase }
14
+ found_key ? super(found_key) : nil
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Switest
4
+ class Configuration
5
+ attr_accessor :host, :port, :password, :default_timeout
6
+
7
+ def initialize
8
+ @host = "127.0.0.1"
9
+ @port = 8021
10
+ @password = "ClueCon"
11
+ @default_timeout = 5
12
+ end
13
+
14
+ def to_h
15
+ {
16
+ host: @host,
17
+ port: @port,
18
+ password: @password,
19
+ default_timeout: @default_timeout
20
+ }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Switest
6
+ module ESL
7
+ class Call
8
+ attr_reader :id, :to, :from, :headers, :direction
9
+ attr_reader :start_time, :answer_time, :end_time, :end_reason
10
+ attr_reader :state
11
+
12
+ def initialize(id:, connection:, direction:, to: nil, from: nil, headers: {})
13
+ @id = id
14
+ @connection = connection
15
+ @direction = direction # :inbound or :outbound
16
+ @to = to
17
+ @from = from
18
+ @headers = Switest::CaseInsensitiveHash.from(headers)
19
+
20
+ @state = :offered
21
+ @start_time = Time.now
22
+ @answer_time = nil
23
+ @end_time = nil
24
+ @end_reason = nil
25
+
26
+ @bridged = false
27
+ @dtmf_buffer = Queue.new
28
+ @callbacks = { answer: [], bridge: [], end: [] }
29
+ @mutex = Mutex.new
30
+
31
+ @answered_latch = Concurrent::CountDownLatch.new(1)
32
+ @bridged_latch = Concurrent::CountDownLatch.new(1)
33
+ @ended_latch = Concurrent::CountDownLatch.new(1)
34
+ end
35
+
36
+ # State queries
37
+ def alive?
38
+ @state != :ended
39
+ end
40
+
41
+ def active?
42
+ @state == :answered
43
+ end
44
+
45
+ def answered?
46
+ @state == :answered || (@state == :ended && @answer_time)
47
+ end
48
+
49
+ def ended?
50
+ @state == :ended
51
+ end
52
+
53
+ def bridged?
54
+ @bridged
55
+ end
56
+
57
+ def inbound?
58
+ @direction == :inbound
59
+ end
60
+
61
+ def outbound?
62
+ @direction == :outbound
63
+ end
64
+
65
+ # Actions
66
+ def answer(wait: 5)
67
+ return unless @state == :offered && inbound?
68
+ sendmsg("execute", "answer")
69
+ return unless wait
70
+ wait_for_answer(timeout: wait)
71
+ end
72
+
73
+ def hangup(cause = "NORMAL_CLEARING", wait: 5)
74
+ return if ended?
75
+ msg = +"sendmsg #{@id}\n"
76
+ msg << "call-command: hangup\n"
77
+ msg << "hangup-cause: #{cause}"
78
+ @connection.send_command(msg)
79
+ return unless wait
80
+ wait_for_end(timeout: wait)
81
+ end
82
+
83
+ def reject(reason = :decline, wait: 5)
84
+ return unless @state == :offered && inbound?
85
+ cause = case reason
86
+ when :busy then "USER_BUSY"
87
+ when :decline then "CALL_REJECTED"
88
+ else "CALL_REJECTED"
89
+ end
90
+ hangup(cause, wait: wait)
91
+ end
92
+
93
+ def play_audio(url, wait: true)
94
+ sendmsg("execute", "playback", url, event_lock: wait)
95
+ end
96
+
97
+ def send_dtmf(digits, wait: true)
98
+ play_audio("tone_stream://d=200;w=250;#{digits}", wait: wait)
99
+ end
100
+
101
+ def receive_dtmf(count: 1, timeout: 5)
102
+ digits = String.new
103
+ deadline = Time.now + timeout
104
+
105
+ count.times do
106
+ remaining = deadline - Time.now
107
+ break if remaining <= 0
108
+
109
+ begin
110
+ digit = @dtmf_buffer.pop(timeout: remaining)
111
+ digits << digit if digit
112
+ rescue ThreadError
113
+ break # Timeout
114
+ end
115
+ end
116
+
117
+ digits
118
+ end
119
+
120
+ # Callbacks
121
+ def on_answer(&block)
122
+ @mutex.synchronize { @callbacks[:answer] << block }
123
+ end
124
+
125
+ def on_bridge(&block)
126
+ @mutex.synchronize { @callbacks[:bridge] << block }
127
+ end
128
+
129
+ def on_end(&block)
130
+ @mutex.synchronize { @callbacks[:end] << block }
131
+ end
132
+
133
+ # Blocking waits
134
+ def wait_for_answer(timeout: 5)
135
+ @answered_latch.wait(timeout)
136
+ answered?
137
+ end
138
+
139
+ def wait_for_bridge(timeout: 5)
140
+ @bridged_latch.wait(timeout)
141
+ bridged?
142
+ end
143
+
144
+ def wait_for_end(timeout: 5)
145
+ @ended_latch.wait(timeout)
146
+ ended?
147
+ end
148
+
149
+ # Internal state updates (called by Client)
150
+ def handle_answer
151
+ @mutex.synchronize do
152
+ return if @state == :ended
153
+ @state = :answered
154
+ @answer_time = Time.now
155
+ end
156
+ @answered_latch.count_down
157
+ fire_callbacks(:answer)
158
+ end
159
+
160
+ def handle_bridge
161
+ @mutex.synchronize do
162
+ return if @state == :ended
163
+ @bridged = true
164
+ end
165
+ @bridged_latch.count_down
166
+ fire_callbacks(:bridge)
167
+ end
168
+
169
+ def handle_hangup(cause, headers = {})
170
+ @mutex.synchronize do
171
+ return if @state == :ended
172
+ @state = :ended
173
+ @end_time = Time.now
174
+ @end_reason = cause
175
+ @headers.merge!(headers)
176
+ end
177
+ @answered_latch.count_down # Release any waiting threads
178
+ @bridged_latch.count_down
179
+ @ended_latch.count_down
180
+ fire_callbacks(:end)
181
+ end
182
+
183
+ def handle_dtmf(digit)
184
+ @dtmf_buffer.push(digit)
185
+ end
186
+
187
+ private
188
+
189
+ def sendmsg(command, app = nil, arg = nil, event_lock: false)
190
+ msg = +"sendmsg #{@id}\n"
191
+ msg << "call-command: #{command}\n"
192
+ msg << "execute-app-name: #{app}\n" if app
193
+ msg << "execute-app-arg: #{arg}\n" if arg
194
+ msg << "event-lock: true\n" if event_lock
195
+ @connection.send_command(msg.chomp)
196
+ end
197
+
198
+
199
+ def fire_callbacks(type)
200
+ callbacks = @mutex.synchronize { @callbacks[type].dup }
201
+ callbacks.each { |cb| cb.call(self) }
202
+ end
203
+ end
204
+ end
205
+ end