actioncable 5.0.0 → 5.0.7.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/CHANGELOG.md +118 -0
- data/README.md +21 -2
- data/lib/action_cable/channel/base.rb +23 -17
- data/lib/action_cable/channel/streams.rb +1 -1
- data/lib/action_cable/connection/authorization.rb +5 -5
- data/lib/action_cable/connection/base.rb +4 -1
- data/lib/action_cable/connection/stream.rb +58 -2
- data/lib/action_cable/connection/stream_event_loop.rb +39 -8
- data/lib/action_cable/connection/subscriptions.rb +5 -1
- data/lib/action_cable/gem_version.rb +2 -2
- data/lib/action_cable/server/base.rb +6 -2
- data/lib/action_cable/server/configuration.rb +2 -1
- data/lib/action_cable/server/worker.rb +1 -1
- data/lib/action_cable/subscription_adapter/evented_redis.rb +2 -2
- data/lib/action_cable/subscription_adapter/postgresql.rb +1 -1
- data/lib/action_cable/subscription_adapter/subscriber_map.rb +5 -1
- data/lib/assets/compiled/action_cable.js +566 -559
- data/lib/rails/generators/channel/templates/application_cable/channel.rb +0 -1
- data/lib/rails/generators/channel/templates/application_cable/connection.rb +0 -1
- data/lib/rails/generators/channel/templates/channel.rb +0 -1
- metadata +13 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1ee3d1102059bedcc5ad921ef625016fad5415c99cefbec0eced71935b94c282
|
4
|
+
data.tar.gz: e79965fac70838822e54e60768b0daf3f846582574f213b36c6e2cf2a44d4564
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bb979e416083f480d596d0946252af8b444c6a5aaedd927ba5b339408ea1ae9eca28f858b0539696b39467d0f38dfc34959b3d5f0b079747c1aa75b6e838cbd9
|
7
|
+
data.tar.gz: 38fd77b3eed559c9ceed9699c58c0d0fdcd07cd86315e5d0932eaa7d712fbbb7794b30a2ae51b6c31c912e4dbbe76726513c2a9805637cdd1e5fad561a6b9f45
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,121 @@
|
|
1
|
+
## Rails 5.0.7.2 (March 11, 2019) ##
|
2
|
+
|
3
|
+
* No changes.
|
4
|
+
|
5
|
+
|
6
|
+
## Rails 5.0.7.1 (November 27, 2018) ##
|
7
|
+
|
8
|
+
* No changes.
|
9
|
+
|
10
|
+
|
11
|
+
## Rails 5.0.7 (March 29, 2018) ##
|
12
|
+
|
13
|
+
* No changes.
|
14
|
+
|
15
|
+
|
16
|
+
## Rails 5.0.6 (September 07, 2017) ##
|
17
|
+
|
18
|
+
* No changes.
|
19
|
+
|
20
|
+
|
21
|
+
## Rails 5.0.6.rc1 (August 24, 2017) ##
|
22
|
+
|
23
|
+
* No changes.
|
24
|
+
|
25
|
+
|
26
|
+
## Rails 5.0.5 (July 31, 2017) ##
|
27
|
+
|
28
|
+
* No changes.
|
29
|
+
|
30
|
+
|
31
|
+
## Rails 5.0.5.rc2 (July 25, 2017) ##
|
32
|
+
|
33
|
+
* No changes.
|
34
|
+
|
35
|
+
|
36
|
+
## Rails 5.0.5.rc1 (July 19, 2017) ##
|
37
|
+
|
38
|
+
* No changes.
|
39
|
+
|
40
|
+
|
41
|
+
## Rails 5.0.4 (June 19, 2017) ##
|
42
|
+
|
43
|
+
* No changes.
|
44
|
+
|
45
|
+
|
46
|
+
## Rails 5.0.3 (May 12, 2017) ##
|
47
|
+
|
48
|
+
* No changes.
|
49
|
+
|
50
|
+
|
51
|
+
## Rails 5.0.2 (March 01, 2017) ##
|
52
|
+
|
53
|
+
* No changes.
|
54
|
+
|
55
|
+
|
56
|
+
## Rails 5.0.1 (December 21, 2016) ##
|
57
|
+
|
58
|
+
* No changes.
|
59
|
+
|
60
|
+
|
61
|
+
## Rails 5.0.1.rc2 (December 10, 2016) ##
|
62
|
+
|
63
|
+
* No changes.
|
64
|
+
|
65
|
+
|
66
|
+
## Rails 5.0.1.rc1 (December 01, 2016) ##
|
67
|
+
|
68
|
+
* Permit same-origin connections by default.
|
69
|
+
|
70
|
+
New option `config.action_cable.allow_same_origin_as_host = false`
|
71
|
+
to disable.
|
72
|
+
|
73
|
+
*Dávid Halász*, *Matthew Draper*
|
74
|
+
|
75
|
+
* Fixed and added a workaround to avoid race condition, when one
|
76
|
+
thread closed the IO, when an another thread was still trying read
|
77
|
+
from IO on a connection.
|
78
|
+
|
79
|
+
*Matthew Draper*
|
80
|
+
|
81
|
+
* Shutdown pubsub connection before classes are reloaded, to avoid
|
82
|
+
hangups caused by pubsub still holding reference to Active Record
|
83
|
+
connection from the pool, and Active Record trying to cleanup the pool.
|
84
|
+
|
85
|
+
*Jon Moss*
|
86
|
+
|
87
|
+
* Prevent race where the client could receive and act upon a
|
88
|
+
subscription confirmation before the channel's `subscribed` method
|
89
|
+
completed.
|
90
|
+
|
91
|
+
Fixes #25381.
|
92
|
+
|
93
|
+
*Vladimir Dementyev*
|
94
|
+
|
95
|
+
* Buffer writes to websocket connections, to avoid blocking threads
|
96
|
+
that could be doing more useful things.
|
97
|
+
|
98
|
+
*Matthew Draper*, *Tinco Andringa*
|
99
|
+
|
100
|
+
* Invocation of channel action is now prevented, if subscription
|
101
|
+
connection was rejected.
|
102
|
+
|
103
|
+
Fixes #23757.
|
104
|
+
|
105
|
+
*Jon Moss*
|
106
|
+
|
107
|
+
* Protect against concurrent writes to a websocket connection from
|
108
|
+
multiple threads; the underlying OS write is not always threadsafe.
|
109
|
+
|
110
|
+
*Tinco Andringa*
|
111
|
+
|
112
|
+
* Close hijacked socket when connection is shut down.
|
113
|
+
|
114
|
+
Fixes #25613.
|
115
|
+
|
116
|
+
*Tinco Andringa*
|
117
|
+
|
118
|
+
|
1
119
|
## Rails 5.0.0 (June 30, 2016) ##
|
2
120
|
|
3
121
|
* Fix development reloading support: new cable connections are now correctly
|
data/README.md
CHANGED
@@ -323,7 +323,10 @@ Rails.application.paths.add "config/cable", with: "somewhere/else/cable.yml"
|
|
323
323
|
|
324
324
|
### Allowed Request Origins
|
325
325
|
|
326
|
-
Action Cable will only accept requests from
|
326
|
+
Action Cable will only accept requests from specific origins.
|
327
|
+
|
328
|
+
By default, only an origin matching the cable server itself will be permitted.
|
329
|
+
Additional origins can be specified using strings or regular expressions, provided in an array.
|
327
330
|
|
328
331
|
```ruby
|
329
332
|
Rails.application.config.action_cable.allowed_request_origins = ['http://rubyonrails.com', /http:\/\/ruby.*/]
|
@@ -331,12 +334,19 @@ Rails.application.config.action_cable.allowed_request_origins = ['http://rubyonr
|
|
331
334
|
|
332
335
|
When running in the development environment, this defaults to "http://localhost:3000".
|
333
336
|
|
334
|
-
To disable and allow requests from any origin:
|
337
|
+
To disable protection and allow requests from any origin:
|
335
338
|
|
336
339
|
```ruby
|
337
340
|
Rails.application.config.action_cable.disable_request_forgery_protection = true
|
338
341
|
```
|
339
342
|
|
343
|
+
To disable automatic access for same-origin requests, and strictly allow
|
344
|
+
only the configured origins:
|
345
|
+
|
346
|
+
```ruby
|
347
|
+
Rails.application.config.action_cable.allow_same_origin_as_host = false
|
348
|
+
```
|
349
|
+
|
340
350
|
### Consumer Configuration
|
341
351
|
|
342
352
|
Once you have decided how to run your cable server (see below), you must provide the server URL (or path) to your client-side setup.
|
@@ -523,6 +533,15 @@ cable.subscriptions.create 'AppearanceChannel',
|
|
523
533
|
# normal channel code goes here...
|
524
534
|
```
|
525
535
|
|
536
|
+
## Download and Installation
|
537
|
+
|
538
|
+
The latest version of Action Cable can be installed with [RubyGems](#gem-usage),
|
539
|
+
or with [npm](#npm-usage).
|
540
|
+
|
541
|
+
Source code can be downloaded as part of the Rails project on GitHub
|
542
|
+
|
543
|
+
* https://github.com/rails/rails/tree/master/actioncable
|
544
|
+
|
526
545
|
## License
|
527
546
|
|
528
547
|
Action Cable is released under the MIT license:
|
@@ -144,13 +144,14 @@ module ActionCable
|
|
144
144
|
|
145
145
|
# When a channel is streaming via pubsub, we want to delay the confirmation
|
146
146
|
# transmission until pubsub subscription is confirmed.
|
147
|
-
|
147
|
+
#
|
148
|
+
# The counter starts at 1 because it's awaiting a call to #subscribe_to_channel
|
149
|
+
@defer_subscription_confirmation_counter = Concurrent::AtomicFixnum.new(1)
|
148
150
|
|
149
151
|
@reject_subscription = nil
|
150
152
|
@subscription_confirmation_sent = nil
|
151
153
|
|
152
154
|
delegate_connection_identifiers
|
153
|
-
subscribe_to_channel
|
154
155
|
end
|
155
156
|
|
156
157
|
# Extract the action name from the passed data and process it via the channel. The process will ensure
|
@@ -169,6 +170,17 @@ module ActionCable
|
|
169
170
|
end
|
170
171
|
end
|
171
172
|
|
173
|
+
# This method is called after subscription has been added to the connection
|
174
|
+
# and confirms or rejects the subscription.
|
175
|
+
def subscribe_to_channel
|
176
|
+
run_callbacks :subscribe do
|
177
|
+
subscribed
|
178
|
+
end
|
179
|
+
|
180
|
+
reject_subscription if subscription_rejected?
|
181
|
+
ensure_confirmation_sent
|
182
|
+
end
|
183
|
+
|
172
184
|
# Called by the cable connection when it's cut, so the channel has a chance to cleanup with callbacks.
|
173
185
|
# This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback.
|
174
186
|
def unsubscribe_from_channel # :nodoc:
|
@@ -202,12 +214,18 @@ module ActionCable
|
|
202
214
|
end
|
203
215
|
end
|
204
216
|
|
217
|
+
def ensure_confirmation_sent
|
218
|
+
return if subscription_rejected?
|
219
|
+
@defer_subscription_confirmation_counter.decrement
|
220
|
+
transmit_subscription_confirmation unless defer_subscription_confirmation?
|
221
|
+
end
|
222
|
+
|
205
223
|
def defer_subscription_confirmation!
|
206
|
-
@
|
224
|
+
@defer_subscription_confirmation_counter.increment
|
207
225
|
end
|
208
226
|
|
209
227
|
def defer_subscription_confirmation?
|
210
|
-
@
|
228
|
+
@defer_subscription_confirmation_counter.value > 0
|
211
229
|
end
|
212
230
|
|
213
231
|
def subscription_confirmation_sent?
|
@@ -231,24 +249,12 @@ module ActionCable
|
|
231
249
|
end
|
232
250
|
end
|
233
251
|
|
234
|
-
def subscribe_to_channel
|
235
|
-
run_callbacks :subscribe do
|
236
|
-
subscribed
|
237
|
-
end
|
238
|
-
|
239
|
-
if subscription_rejected?
|
240
|
-
reject_subscription
|
241
|
-
else
|
242
|
-
transmit_subscription_confirmation unless defer_subscription_confirmation?
|
243
|
-
end
|
244
|
-
end
|
245
|
-
|
246
252
|
def extract_action(data)
|
247
253
|
(data['action'].presence || :receive).to_sym
|
248
254
|
end
|
249
255
|
|
250
256
|
def processable_action?(action)
|
251
|
-
self.class.action_methods.include?(action.to_s)
|
257
|
+
self.class.action_methods.include?(action.to_s) unless subscription_rejected?
|
252
258
|
end
|
253
259
|
|
254
260
|
def dispatch_action(action, data)
|
@@ -84,7 +84,7 @@ module ActionCable
|
|
84
84
|
|
85
85
|
connection.server.event_loop.post do
|
86
86
|
pubsub.subscribe(broadcasting, handler, lambda do
|
87
|
-
|
87
|
+
ensure_confirmation_sent
|
88
88
|
logger.info "#{self.class.name} is streaming from #{broadcasting}"
|
89
89
|
end)
|
90
90
|
end
|
@@ -3,11 +3,11 @@ module ActionCable
|
|
3
3
|
module Authorization
|
4
4
|
class UnauthorizedError < StandardError; end
|
5
5
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
6
|
+
# Closes the \WebSocket connection if it is open and returns a 404 "File not Found" response.
|
7
|
+
def reject_unauthorized_connection
|
8
|
+
logger.error "An unauthorized connection attempt was rejected"
|
9
|
+
raise UnauthorizedError
|
10
|
+
end
|
11
11
|
end
|
12
12
|
end
|
13
13
|
end
|
@@ -195,7 +195,10 @@ module ActionCable
|
|
195
195
|
def allow_request_origin?
|
196
196
|
return true if server.config.disable_request_forgery_protection
|
197
197
|
|
198
|
-
|
198
|
+
proto = Rack::Request.new(env).ssl? ? "https" : "http"
|
199
|
+
if server.config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env['HTTP_HOST']}"
|
200
|
+
true
|
201
|
+
elsif Array(server.config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] }
|
199
202
|
true
|
200
203
|
else
|
201
204
|
logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}")
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
1
3
|
module ActionCable
|
2
4
|
module Connection
|
3
5
|
#--
|
@@ -11,6 +13,10 @@ module ActionCable
|
|
11
13
|
@stream_send = socket.env['stream.send']
|
12
14
|
|
13
15
|
@rack_hijack_io = nil
|
16
|
+
@write_lock = Mutex.new
|
17
|
+
|
18
|
+
@write_head = nil
|
19
|
+
@write_buffer = Queue.new
|
14
20
|
end
|
15
21
|
|
16
22
|
def each(&callback)
|
@@ -27,12 +33,62 @@ module ActionCable
|
|
27
33
|
end
|
28
34
|
|
29
35
|
def write(data)
|
30
|
-
|
31
|
-
|
36
|
+
if @stream_send
|
37
|
+
return @stream_send.call(data)
|
38
|
+
end
|
39
|
+
|
40
|
+
if @write_lock.try_lock
|
41
|
+
begin
|
42
|
+
if @write_head.nil? && @write_buffer.empty?
|
43
|
+
written = @rack_hijack_io.write_nonblock(data, exception: false)
|
44
|
+
|
45
|
+
case written
|
46
|
+
when :wait_writable
|
47
|
+
# proceed below
|
48
|
+
when data.bytesize
|
49
|
+
return data.bytesize
|
50
|
+
else
|
51
|
+
@write_head = data.byteslice(written, data.bytesize)
|
52
|
+
@event_loop.writes_pending @rack_hijack_io
|
53
|
+
|
54
|
+
return data.bytesize
|
55
|
+
end
|
56
|
+
end
|
57
|
+
ensure
|
58
|
+
@write_lock.unlock
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
@write_buffer << data
|
63
|
+
@event_loop.writes_pending @rack_hijack_io
|
64
|
+
|
65
|
+
data.bytesize
|
32
66
|
rescue EOFError, Errno::ECONNRESET
|
33
67
|
@socket_object.client_gone
|
34
68
|
end
|
35
69
|
|
70
|
+
def flush_write_buffer
|
71
|
+
@write_lock.synchronize do
|
72
|
+
loop do
|
73
|
+
if @write_head.nil?
|
74
|
+
return true if @write_buffer.empty?
|
75
|
+
@write_head = @write_buffer.pop
|
76
|
+
end
|
77
|
+
|
78
|
+
written = @rack_hijack_io.write_nonblock(@write_head, exception: false)
|
79
|
+
case written
|
80
|
+
when :wait_writable
|
81
|
+
return false
|
82
|
+
when @write_head.bytesize
|
83
|
+
@write_head = nil
|
84
|
+
else
|
85
|
+
@write_head = @write_head.byteslice(written, @write_head.bytesize)
|
86
|
+
return false
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
36
92
|
def receive(data)
|
37
93
|
@socket_object.parse(data)
|
38
94
|
end
|
@@ -5,7 +5,7 @@ module ActionCable
|
|
5
5
|
module Connection
|
6
6
|
class StreamEventLoop
|
7
7
|
def initialize
|
8
|
-
@nio = @thread = nil
|
8
|
+
@nio = @executor = @thread = nil
|
9
9
|
@map = {}
|
10
10
|
@stopping = false
|
11
11
|
@todo = Queue.new
|
@@ -20,13 +20,14 @@ module ActionCable
|
|
20
20
|
def post(task = nil, &block)
|
21
21
|
task ||= block
|
22
22
|
|
23
|
-
|
23
|
+
spawn
|
24
|
+
@executor << task
|
24
25
|
end
|
25
26
|
|
26
27
|
def attach(io, stream)
|
27
28
|
@todo << lambda do
|
28
|
-
@map[io] =
|
29
|
-
@
|
29
|
+
@map[io] = @nio.register(io, :r)
|
30
|
+
@map[io].value = stream
|
30
31
|
end
|
31
32
|
wakeup
|
32
33
|
end
|
@@ -35,6 +36,16 @@ module ActionCable
|
|
35
36
|
@todo << lambda do
|
36
37
|
@nio.deregister io
|
37
38
|
@map.delete io
|
39
|
+
io.close
|
40
|
+
end
|
41
|
+
wakeup
|
42
|
+
end
|
43
|
+
|
44
|
+
def writes_pending(io)
|
45
|
+
@todo << lambda do
|
46
|
+
if monitor = @map[io]
|
47
|
+
monitor.interests = :rw
|
48
|
+
end
|
38
49
|
end
|
39
50
|
wakeup
|
40
51
|
end
|
@@ -52,6 +63,13 @@ module ActionCable
|
|
52
63
|
return if @thread && @thread.status
|
53
64
|
|
54
65
|
@nio ||= NIO::Selector.new
|
66
|
+
|
67
|
+
@executor ||= Concurrent::ThreadPoolExecutor.new(
|
68
|
+
min_threads: 1,
|
69
|
+
max_threads: 10,
|
70
|
+
max_queue: 0,
|
71
|
+
)
|
72
|
+
|
55
73
|
@thread = Thread.new { run }
|
56
74
|
|
57
75
|
return true
|
@@ -77,12 +95,25 @@ module ActionCable
|
|
77
95
|
|
78
96
|
monitors.each do |monitor|
|
79
97
|
io = monitor.io
|
80
|
-
stream =
|
98
|
+
stream = monitor.value
|
81
99
|
|
82
100
|
begin
|
83
|
-
|
84
|
-
|
85
|
-
|
101
|
+
if monitor.writable?
|
102
|
+
if stream.flush_write_buffer
|
103
|
+
monitor.interests = :r
|
104
|
+
end
|
105
|
+
next unless monitor.readable?
|
106
|
+
end
|
107
|
+
|
108
|
+
incoming = io.read_nonblock(4096, exception: false)
|
109
|
+
case incoming
|
110
|
+
when :wait_readable
|
111
|
+
next
|
112
|
+
when nil
|
113
|
+
stream.close
|
114
|
+
else
|
115
|
+
stream.receive incoming
|
116
|
+
end
|
86
117
|
rescue
|
87
118
|
# We expect one of EOFError or Errno::ECONNRESET in
|
88
119
|
# normal operation (when the client goes away). But if
|
@@ -26,10 +26,14 @@ module ActionCable
|
|
26
26
|
id_key = data['identifier']
|
27
27
|
id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
|
28
28
|
|
29
|
+
return if subscriptions.key?(id_key)
|
30
|
+
|
29
31
|
subscription_klass = id_options[:channel].safe_constantize
|
30
32
|
|
31
33
|
if subscription_klass && ActionCable::Channel::Base >= subscription_klass
|
32
|
-
|
34
|
+
subscription = subscription_klass.new(connection, id_key, id_options)
|
35
|
+
subscriptions[id_key] = subscription
|
36
|
+
subscription.subscribe_to_channel
|
33
37
|
else
|
34
38
|
logger.error "Subscription class not found: #{id_options[:channel].inspect}"
|
35
39
|
end
|
@@ -37,9 +37,13 @@ module ActionCable
|
|
37
37
|
connections.each(&:close)
|
38
38
|
|
39
39
|
@mutex.synchronize do
|
40
|
-
|
41
|
-
|
40
|
+
# Shutdown the worker pool
|
41
|
+
@worker_pool.halt if @worker_pool
|
42
42
|
@worker_pool = nil
|
43
|
+
|
44
|
+
# Shutdown the pub/sub adapter
|
45
|
+
@pubsub.shutdown if @pubsub
|
46
|
+
@pubsub = nil
|
43
47
|
end
|
44
48
|
end
|
45
49
|
|
@@ -5,7 +5,7 @@ module ActionCable
|
|
5
5
|
class Configuration
|
6
6
|
attr_accessor :logger, :log_tags
|
7
7
|
attr_accessor :use_faye, :connection_class, :worker_pool_size
|
8
|
-
attr_accessor :disable_request_forgery_protection, :allowed_request_origins
|
8
|
+
attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host
|
9
9
|
attr_accessor :cable, :url, :mount_path
|
10
10
|
|
11
11
|
def initialize
|
@@ -15,6 +15,7 @@ module ActionCable
|
|
15
15
|
@worker_pool_size = 4
|
16
16
|
|
17
17
|
@disable_request_forgery_protection = false
|
18
|
+
@allow_same_origin_as_host = true
|
18
19
|
end
|
19
20
|
|
20
21
|
# Returns constant of subscription adapter specified in config/cable.yml.
|
@@ -68,10 +68,10 @@ module ActionCable
|
|
68
68
|
end
|
69
69
|
|
70
70
|
def ensure_reactor_running
|
71
|
-
return if EventMachine.reactor_running?
|
71
|
+
return if EventMachine.reactor_running? && EventMachine.reactor_thread
|
72
72
|
@@mutex.synchronize do
|
73
73
|
Thread.new { EventMachine.run } unless EventMachine.reactor_running?
|
74
|
-
Thread.pass until EventMachine.reactor_running?
|
74
|
+
Thread.pass until EventMachine.reactor_running? && EventMachine.reactor_thread
|
75
75
|
end
|
76
76
|
end
|
77
77
|
end
|
@@ -32,7 +32,11 @@ module ActionCable
|
|
32
32
|
end
|
33
33
|
|
34
34
|
def broadcast(channel, message)
|
35
|
-
list = @sync.synchronize
|
35
|
+
list = @sync.synchronize do
|
36
|
+
return if !@subscribers.key?(channel)
|
37
|
+
@subscribers[channel].dup
|
38
|
+
end
|
39
|
+
|
36
40
|
list.each do |subscriber|
|
37
41
|
invoke_callback(subscriber, message)
|
38
42
|
end
|