loading
Generated 2020-08-25T22:38:08-04:00

All Files ( 80.85% covered at 51.99 hits/line )

47 files in total.
1629 relevant lines, 1317 lines covered and 312 lines missed. ( 80.85% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/action_cable.rb 100.00 % 62 15 15 0 33.67
lib/action_cable/channel.rb 100.00 % 17 11 11 0 1.00
lib/action_cable/channel/base.rb 100.00 % 311 104 104 0 82.48
lib/action_cable/channel/broadcasting.rb 100.00 % 41 16 16 0 12.44
lib/action_cable/channel/callbacks.rb 90.00 % 37 20 18 2 1.05
lib/action_cable/channel/naming.rb 100.00 % 25 8 8 0 3.50
lib/action_cable/channel/periodic_timers.rb 96.97 % 78 33 32 1 21.97
lib/action_cable/channel/streams.rb 100.00 % 202 57 57 0 62.56
lib/action_cable/channel/test_case.rb 95.60 % 310 91 87 4 3.95
lib/action_cable/connection.rb 100.00 % 22 16 16 0 1.00
lib/action_cable/connection/authorization.rb 100.00 % 15 7 7 0 2.43
lib/action_cable/connection/base.rb 98.18 % 266 110 108 2 81.45
lib/action_cable/connection/client_socket.rb 93.26 % 157 89 83 6 109.16
lib/action_cable/connection/identification.rb 100.00 % 47 21 21 0 32.86
lib/action_cable/connection/internal_channel.rb 87.50 % 45 24 21 3 13.71
lib/action_cable/connection/message_buffer.rb 89.29 % 54 28 25 3 78.68
lib/action_cable/connection/stream.rb 65.00 % 117 60 39 21 136.27
lib/action_cable/connection/stream_event_loop.rb 76.39 % 136 72 55 17 111.24
lib/action_cable/connection/subscriptions.rb 95.45 % 80 44 42 2 62.77
lib/action_cable/connection/tagged_logger_proxy.rb 100.00 % 42 21 21 0 376.48
lib/action_cable/connection/test_case.rb 96.20 % 234 79 76 3 4.96
lib/action_cable/connection/web_socket.rb 100.00 % 41 20 20 0 82.70
lib/action_cable/engine.rb 0.00 % 79 61 0 61 0.00
lib/action_cable/gem_version.rb 88.89 % 17 9 8 1 0.89
lib/action_cable/helpers/action_cable_helper.rb 0.00 % 42 13 0 13 0.00
lib/action_cable/remote_connections.rb 96.43 % 71 28 27 1 1.00
lib/action_cable/server.rb 100.00 % 16 9 9 0 1.00
lib/action_cable/server/base.rb 97.44 % 94 39 38 1 38.31
lib/action_cable/server/broadcasting.rb 100.00 % 54 18 18 0 15.06
lib/action_cable/server/configuration.rb 87.50 % 56 24 21 3 25.88
lib/action_cable/server/connections.rb 93.33 % 36 15 14 1 48.33
lib/action_cable/server/worker.rb 83.78 % 75 37 31 6 125.14
lib/action_cable/server/worker/active_record_connection_management.rb 100.00 % 21 10 10 0 115.10
lib/action_cable/subscription_adapter.rb 100.00 % 12 7 7 0 1.00
lib/action_cable/subscription_adapter/async.rb 100.00 % 29 15 15 0 44.40
lib/action_cable/subscription_adapter/base.rb 94.12 % 34 17 16 1 45.24
lib/action_cable/subscription_adapter/channel_prefix.rb 100.00 % 28 15 15 0 24.53
lib/action_cable/subscription_adapter/inline.rb 100.00 % 37 18 18 0 74.61
lib/action_cable/subscription_adapter/postgresql.rb 0.00 % 133 107 0 107 0.00
lib/action_cable/subscription_adapter/redis.rb 100.00 % 180 94 94 0 32.20
lib/action_cable/subscription_adapter/subscriber_map.rb 100.00 % 59 31 31 0 94.61
lib/action_cable/subscription_adapter/test.rb 100.00 % 40 16 16 0 15.81
lib/action_cable/test_case.rb 100.00 % 11 5 5 0 1.00
lib/action_cable/test_helper.rb 100.00 % 133 39 39 0 40.05
lib/action_cable/version.rb 75.00 % 10 4 3 1 0.75
lib/rails/generators/channel/channel_generator.rb 0.00 % 52 37 0 37 0.00
lib/rails/generators/test_unit/channel_generator.rb 0.00 % 20 15 0 15 0.00

lib/action_cable.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. #--
  3. # Copyright (c) 2015-2020 Basecamp, LLC
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining
  6. # a copy of this software and associated documentation files (the
  7. # "Software"), to deal in the Software without restriction, including
  8. # without limitation the rights to use, copy, modify, merge, publish,
  9. # distribute, sublicense, and/or sell copies of the Software, and to
  10. # permit persons to whom the Software is furnished to do so, subject to
  11. # the following conditions:
  12. #
  13. # The above copyright notice and this permission notice shall be
  14. # included in all copies or substantial portions of the Software.
  15. #
  16. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  17. # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  18. # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  19. # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
  20. # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
  21. # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
  22. # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  23. #++
  24. 1 require "active_support"
  25. 1 require "active_support/rails"
  26. 1 require "action_cable/version"
  27. 1 module ActionCable
  28. 1 extend ActiveSupport::Autoload
  29. 1 INTERNAL = {
  30. message_types: {
  31. welcome: "welcome",
  32. disconnect: "disconnect",
  33. ping: "ping",
  34. confirmation: "confirm_subscription",
  35. rejection: "reject_subscription"
  36. },
  37. disconnect_reasons: {
  38. unauthorized: "unauthorized",
  39. invalid_request: "invalid_request",
  40. server_restart: "server_restart"
  41. },
  42. default_mount_path: "/cable",
  43. protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze
  44. }
  45. # Singleton instance of the server
  46. 1 module_function def server
  47. 491 @server ||= ActionCable::Server::Base.new
  48. end
  49. 1 autoload :Server
  50. 1 autoload :Connection
  51. 1 autoload :Channel
  52. 1 autoload :RemoteConnections
  53. 1 autoload :SubscriptionAdapter
  54. 1 autoload :TestHelper
  55. 1 autoload :TestCase
  56. end

lib/action_cable/channel.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Channel
  4. 1 extend ActiveSupport::Autoload
  5. 1 eager_autoload do
  6. 1 autoload :Base
  7. 1 autoload :Broadcasting
  8. 1 autoload :Callbacks
  9. 1 autoload :Naming
  10. 1 autoload :PeriodicTimers
  11. 1 autoload :Streams
  12. 1 autoload :TestCase
  13. end
  14. end
  15. end

lib/action_cable/channel/base.rb

100.0% lines covered

104 relevant lines. 104 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "set"
  3. 1 require "active_support/rescuable"
  4. 1 module ActionCable
  5. 1 module Channel
  6. # The channel provides the basic structure of grouping behavior into logical units when communicating over the WebSocket connection.
  7. # You can think of a channel like a form of controller, but one that's capable of pushing content to the subscriber in addition to simply
  8. # responding to the subscriber's direct requests.
  9. #
  10. # Channel instances are long-lived. A channel object will be instantiated when the cable consumer becomes a subscriber, and then
  11. # lives until the consumer disconnects. This may be seconds, minutes, hours, or even days. That means you have to take special care
  12. # not to do anything silly in a channel that would balloon its memory footprint or whatever. The references are forever, so they won't be released
  13. # as is normally the case with a controller instance that gets thrown away after every request.
  14. #
  15. # Long-lived channels (and connections) also mean you're responsible for ensuring that the data is fresh. If you hold a reference to a user
  16. # record, but the name is changed while that reference is held, you may be sending stale data if you don't take precautions to avoid it.
  17. #
  18. # The upside of long-lived channel instances is that you can use instance variables to keep reference to objects that future subscriber requests
  19. # can interact with. Here's a quick example:
  20. #
  21. # class ChatChannel < ApplicationCable::Channel
  22. # def subscribed
  23. # @room = Chat::Room[params[:room_number]]
  24. # end
  25. #
  26. # def speak(data)
  27. # @room.speak data, user: current_user
  28. # end
  29. # end
  30. #
  31. # The #speak action simply uses the Chat::Room object that was created when the channel was first subscribed to by the consumer when that
  32. # subscriber wants to say something in the room.
  33. #
  34. # == Action processing
  35. #
  36. # Unlike subclasses of ActionController::Base, channels do not follow a RESTful
  37. # constraint form for their actions. Instead, Action Cable operates through a
  38. # remote-procedure call model. You can declare any public method on the
  39. # channel (optionally taking a <tt>data</tt> argument), and this method is
  40. # automatically exposed as callable to the client.
  41. #
  42. # Example:
  43. #
  44. # class AppearanceChannel < ApplicationCable::Channel
  45. # def subscribed
  46. # @connection_token = generate_connection_token
  47. # end
  48. #
  49. # def unsubscribed
  50. # current_user.disappear @connection_token
  51. # end
  52. #
  53. # def appear(data)
  54. # current_user.appear @connection_token, on: data['appearing_on']
  55. # end
  56. #
  57. # def away
  58. # current_user.away @connection_token
  59. # end
  60. #
  61. # private
  62. # def generate_connection_token
  63. # SecureRandom.hex(36)
  64. # end
  65. # end
  66. #
  67. # In this example, the subscribed and unsubscribed methods are not callable methods, as they
  68. # were already declared in ActionCable::Channel::Base, but <tt>#appear</tt>
  69. # and <tt>#away</tt> are. <tt>#generate_connection_token</tt> is also not
  70. # callable, since it's a private method. You'll see that appear accepts a data
  71. # parameter, which it then uses as part of its model call. <tt>#away</tt>
  72. # does not, since it's simply a trigger action.
  73. #
  74. # Also note that in this example, <tt>current_user</tt> is available because
  75. # it was marked as an identifying attribute on the connection. All such
  76. # identifiers will automatically create a delegation method of the same name
  77. # on the channel instance.
  78. #
  79. # == Rejecting subscription requests
  80. #
  81. # A channel can reject a subscription request in the #subscribed callback by
  82. # invoking the #reject method:
  83. #
  84. # class ChatChannel < ApplicationCable::Channel
  85. # def subscribed
  86. # @room = Chat::Room[params[:room_number]]
  87. # reject unless current_user.can_access?(@room)
  88. # end
  89. # end
  90. #
  91. # In this example, the subscription will be rejected if the
  92. # <tt>current_user</tt> does not have access to the chat room. On the
  93. # client-side, the <tt>Channel#rejected</tt> callback will get invoked when
  94. # the server rejects the subscription request.
  95. 1 class Base
  96. 1 include Callbacks
  97. 1 include PeriodicTimers
  98. 1 include Streams
  99. 1 include Naming
  100. 1 include Broadcasting
  101. 1 include ActiveSupport::Rescuable
  102. 1 attr_reader :params, :connection, :identifier
  103. 1 delegate :logger, to: :connection
  104. 1 class << self
  105. # A list of method names that should be considered actions. This
  106. # includes all public instance methods on a channel, less
  107. # any internal methods (defined on Base), adding back in
  108. # any methods that are internal, but still exist on the class
  109. # itself.
  110. #
  111. # ==== Returns
  112. # * <tt>Set</tt> - A set of all methods that should be considered actions.
  113. 1 def action_methods
  114. 143 @action_methods ||= begin
  115. # All public instance methods of this class, including ancestors
  116. 5 methods = (public_instance_methods(true) -
  117. # Except for public instance methods of Base and its ancestors
  118. 5 ActionCable::Channel::Base.public_instance_methods(true) +
  119. # Be sure to include shadowed public instance methods of this class
  120. public_instance_methods(false)).uniq.map(&:to_s)
  121. 5 methods.to_set
  122. end
  123. end
  124. 1 private
  125. # action_methods are cached and there is sometimes need to refresh
  126. # them. ::clear_action_methods! allows you to do that, so next time
  127. # you run action_methods, they will be recalculated.
  128. 1 def clear_action_methods! # :doc:
  129. 66 @action_methods = nil
  130. end
  131. # Refresh the cached action_methods when a new action_method is added.
  132. 1 def method_added(name) # :doc:
  133. 66 super
  134. 66 clear_action_methods!
  135. end
  136. end
  137. 1 def initialize(connection, identifier, params = {})
  138. 174 @connection = connection
  139. 174 @identifier = identifier
  140. 174 @params = params
  141. # When a channel is streaming via pubsub, we want to delay the confirmation
  142. # transmission until pubsub subscription is confirmed.
  143. #
  144. # The counter starts at 1 because it's awaiting a call to #subscribe_to_channel
  145. 174 @defer_subscription_confirmation_counter = Concurrent::AtomicFixnum.new(1)
  146. 174 @reject_subscription = nil
  147. 174 @subscription_confirmation_sent = nil
  148. 174 delegate_connection_identifiers
  149. end
  150. # Extract the action name from the passed data and process it via the channel. The process will ensure
  151. # that the action requested is a public method on the channel declared by the user (so not one of the callbacks
  152. # like #subscribed).
  153. 1 def perform_action(data)
  154. 143 action = extract_action(data)
  155. 143 if processable_action?(action)
  156. 139 payload = { channel_class: self.class.name, action: action, data: data }
  157. 139 ActiveSupport::Notifications.instrument("perform_action.action_cable", payload) do
  158. 139 dispatch_action(action, data)
  159. end
  160. else
  161. 4 logger.error "Unable to process #{action_signature(action, data)}"
  162. end
  163. end
  164. # This method is called after subscription has been added to the connection
  165. # and confirms or rejects the subscription.
  166. 1 def subscribe_to_channel
  167. 156 run_callbacks :subscribe do
  168. 156 subscribed
  169. end
  170. 156 reject_subscription if subscription_rejected?
  171. 156 ensure_confirmation_sent
  172. end
  173. # Called by the cable connection when it's cut, so the channel has a chance to cleanup with callbacks.
  174. # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback.
  175. 1 def unsubscribe_from_channel # :nodoc:
  176. 119 run_callbacks :unsubscribe do
  177. 119 unsubscribed
  178. end
  179. end
  180. 1 private
  181. # Called once a consumer has become a subscriber of the channel. Usually the place to set up any streams
  182. # you want this channel to be sending to the subscriber.
  183. 1 def subscribed # :doc:
  184. # Override in subclasses
  185. end
  186. # Called once a consumer has cut its cable connection. Can be used for cleaning up connections or marking
  187. # users as offline or the like.
  188. 1 def unsubscribed # :doc:
  189. # Override in subclasses
  190. end
  191. # Transmit a hash of data to the subscriber. The hash will automatically be wrapped in a JSON envelope with
  192. # the proper channel identifier marked as the recipient.
  193. 1 def transmit(data, via: nil) # :doc:
  194. 218 status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}"
  195. 218 status += " (via #{via})" if via
  196. 218 logger.debug(status)
  197. 218 payload = { channel_class: self.class.name, data: data, via: via }
  198. 218 ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do
  199. 218 connection.transmit identifier: @identifier, message: data
  200. end
  201. end
  202. 1 def ensure_confirmation_sent # :doc:
  203. 275 return if subscription_rejected?
  204. 272 @defer_subscription_confirmation_counter.decrement
  205. 272 transmit_subscription_confirmation unless defer_subscription_confirmation?
  206. end
  207. 1 def defer_subscription_confirmation! # :doc:
  208. 134 @defer_subscription_confirmation_counter.increment
  209. end
  210. 1 def defer_subscription_confirmation? # :doc:
  211. 272 @defer_subscription_confirmation_counter.value > 0
  212. end
  213. 1 def subscription_confirmation_sent? # :doc:
  214. 153 @subscription_confirmation_sent
  215. end
  216. 1 def reject # :doc:
  217. 4 @reject_subscription = true
  218. end
  219. 1 def subscription_rejected? # :doc:
  220. 581 @reject_subscription
  221. end
  222. 1 def delegate_connection_identifiers
  223. 174 connection.identifiers.each do |identifier|
  224. 45 define_singleton_method(identifier) do
  225. 6 connection.send(identifier)
  226. end
  227. end
  228. end
  229. 1 def extract_action(data)
  230. 143 (data["action"].presence || :receive).to_sym
  231. end
  232. 1 def processable_action?(action)
  233. 143 self.class.action_methods.include?(action.to_s) unless subscription_rejected?
  234. end
  235. 1 def dispatch_action(action, data)
  236. 139 logger.info action_signature(action, data)
  237. 139 if method(action).arity == 1
  238. 132 public_send action, data
  239. else
  240. 7 public_send action
  241. end
  242. rescue Exception => exception
  243. 2 rescue_with_handler(exception) || raise
  244. end
  245. 1 def action_signature(action, data)
  246. 143 (+"#{self.class.name}##{action}").tap do |signature|
  247. 143 if (arguments = data.except("action")).any?
  248. 133 signature << "(#{arguments.inspect})"
  249. end
  250. end
  251. end
  252. 1 def transmit_subscription_confirmation
  253. 152 unless subscription_confirmation_sent?
  254. 151 logger.debug "#{self.class.name} is transmitting the subscription confirmation"
  255. 151 ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name) do
  256. 151 connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]
  257. 151 @subscription_confirmation_sent = true
  258. end
  259. end
  260. end
  261. 1 def reject_subscription
  262. 3 connection.subscriptions.remove_subscription self
  263. 3 transmit_subscription_rejection
  264. end
  265. 1 def transmit_subscription_rejection
  266. 4 logger.debug "#{self.class.name} is transmitting the subscription rejection"
  267. 4 ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name) do
  268. 4 connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]
  269. end
  270. end
  271. end
  272. end
  273. end
  274. 1 ActiveSupport.run_load_hooks(:action_cable_channel, ActionCable::Channel::Base)

lib/action_cable/channel/broadcasting.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/core_ext/object/to_param"
  3. 1 module ActionCable
  4. 1 module Channel
  5. 1 module Broadcasting
  6. 1 extend ActiveSupport::Concern
  7. 1 delegate :broadcasting_for, :broadcast_to, to: :class
  8. 1 module ClassMethods
  9. # Broadcast a hash to a unique broadcasting for this <tt>model</tt> in this channel.
  10. 1 def broadcast_to(model, message)
  11. 3 ActionCable.server.broadcast(broadcasting_for(model), message)
  12. end
  13. # Returns a unique broadcasting identifier for this <tt>model</tt> in this channel:
  14. #
  15. # CommentsChannel.broadcasting_for("all") # => "comments:all"
  16. #
  17. # You can pass any object as a target (e.g. Active Record model), and it
  18. # would be serialized into a string under the hood.
  19. 1 def broadcasting_for(model)
  20. 20 serialize_broadcasting([ channel_name, model ])
  21. end
  22. 1 def serialize_broadcasting(object) #:nodoc:
  23. case
  24. when object.is_a?(Array)
  25. 63 object.map { |m| serialize_broadcasting(m) }.join(":")
  26. when object.respond_to?(:to_gid_param)
  27. 20 object.to_gid_param
  28. else
  29. 21 object.to_param
  30. 62 end
  31. end
  32. end
  33. end
  34. end
  35. end

lib/action_cable/channel/callbacks.rb

90.0% lines covered

20 relevant lines. 18 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/callbacks"
  3. 1 module ActionCable
  4. 1 module Channel
  5. 1 module Callbacks
  6. 1 extend ActiveSupport::Concern
  7. 1 include ActiveSupport::Callbacks
  8. 1 included do
  9. 1 define_callbacks :subscribe
  10. 1 define_callbacks :unsubscribe
  11. end
  12. 1 module ClassMethods
  13. 1 def before_subscribe(*methods, &block)
  14. set_callback(:subscribe, :before, *methods, &block)
  15. end
  16. 1 def after_subscribe(*methods, &block)
  17. 2 set_callback(:subscribe, :after, *methods, &block)
  18. end
  19. 1 alias_method :on_subscribe, :after_subscribe
  20. 1 def before_unsubscribe(*methods, &block)
  21. set_callback(:unsubscribe, :before, *methods, &block)
  22. end
  23. 1 def after_unsubscribe(*methods, &block)
  24. 3 set_callback(:unsubscribe, :after, *methods, &block)
  25. end
  26. 1 alias_method :on_unsubscribe, :after_unsubscribe
  27. end
  28. end
  29. end
  30. end

lib/action_cable/channel/naming.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Channel
  4. 1 module Naming
  5. 1 extend ActiveSupport::Concern
  6. 1 module ClassMethods
  7. # Returns the name of the channel, underscored, without the <tt>Channel</tt> ending.
  8. # If the channel is in a namespace, then the namespaces are represented by single
  9. # colon separators in the channel name.
  10. #
  11. # ChatChannel.channel_name # => 'chat'
  12. # Chats::AppearancesChannel.channel_name # => 'chats:appearances'
  13. # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances'
  14. 1 def channel_name
  15. 21 @channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore
  16. end
  17. end
  18. # Delegates to the class' <tt>channel_name</tt>
  19. 1 delegate :channel_name, to: :class
  20. end
  21. end
  22. end

lib/action_cable/channel/periodic_timers.rb

96.97% lines covered

33 relevant lines. 32 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Channel
  4. 1 module PeriodicTimers
  5. 1 extend ActiveSupport::Concern
  6. 1 included do
  7. 1 class_attribute :periodic_timers, instance_reader: false, default: []
  8. 1 after_subscribe :start_periodic_timers
  9. 1 after_unsubscribe :stop_periodic_timers
  10. end
  11. 1 module ClassMethods
  12. # Periodically performs a task on the channel, like updating an online
  13. # user counter, polling a backend for new status messages, sending
  14. # regular "heartbeat" messages, or doing some internal work and giving
  15. # progress updates.
  16. #
  17. # Pass a method name or lambda argument or provide a block to call.
  18. # Specify the calling period in seconds using the <tt>every:</tt>
  19. # keyword argument.
  20. #
  21. # periodically :transmit_progress, every: 5.seconds
  22. #
  23. # periodically every: 3.minutes do
  24. # transmit action: :update_count, count: current_count
  25. # end
  26. #
  27. 1 def periodically(callback_or_method_name = nil, every:, &block)
  28. 15 callback =
  29. 15 if block_given?
  30. 2 raise ArgumentError, "Pass a block or provide a callback arg, not both" if callback_or_method_name
  31. 1 block
  32. else
  33. 13 case callback_or_method_name
  34. when Proc
  35. 1 callback_or_method_name
  36. when Symbol
  37. 9 -> { __send__ callback_or_method_name }
  38. else
  39. 3 raise ArgumentError, "Expected a Symbol method name or a Proc, got #{callback_or_method_name.inspect}"
  40. end
  41. end
  42. 11 unless every.kind_of?(Numeric) && every > 0
  43. 8 raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}"
  44. end
  45. 3 self.periodic_timers += [[ callback, every: every ]]
  46. end
  47. end
  48. 1 private
  49. 1 def active_periodic_timers
  50. 240 @active_periodic_timers ||= []
  51. end
  52. 1 def start_periodic_timers
  53. 144 self.class.periodic_timers.each do |callback, options|
  54. 3 active_periodic_timers << start_periodic_timer(callback, every: options.fetch(:every))
  55. end
  56. end
  57. 1 def start_periodic_timer(callback, every:)
  58. 3 connection.server.event_loop.timer every do
  59. connection.worker_pool.async_exec self, connection: connection, &callback
  60. end
  61. end
  62. 1 def stop_periodic_timers
  63. 121 active_periodic_timers.each { |timer| timer.shutdown }
  64. 118 active_periodic_timers.clear
  65. end
  66. end
  67. end
  68. end

lib/action_cable/channel/streams.rb

100.0% lines covered

57 relevant lines. 57 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Channel
  4. # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pubsub queue where any data
  5. # placed into it is automatically sent to the clients that are connected at that time. It's purely an online queue, though. If you're not
  6. # streaming a broadcasting at the very moment it sends out an update, you will not get that update, even if you connect after it has been sent.
  7. #
  8. # Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between
  9. # the two parties (the broadcaster and the channel subscriber). Here's an example of a channel that allows subscribers to get all new
  10. # comments on a given page:
  11. #
  12. # class CommentsChannel < ApplicationCable::Channel
  13. # def follow(data)
  14. # stream_from "comments_for_#{data['recording_id']}"
  15. # end
  16. #
  17. # def unfollow
  18. # stop_all_streams
  19. # end
  20. # end
  21. #
  22. # Based on the above example, the subscribers of this channel will get whatever data is put into the,
  23. # let's say, <tt>comments_for_45</tt> broadcasting as soon as it's put there.
  24. #
  25. # An example broadcasting for this channel looks like so:
  26. #
  27. # ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell'
  28. #
  29. # If you have a stream that is related to a model, then the broadcasting used can be generated from the model and channel.
  30. # The following example would subscribe to a broadcasting like <tt>comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE</tt>.
  31. #
  32. # class CommentsChannel < ApplicationCable::Channel
  33. # def subscribed
  34. # post = Post.find(params[:id])
  35. # stream_for post
  36. # end
  37. # end
  38. #
  39. # You can then broadcast to this channel using:
  40. #
  41. # CommentsChannel.broadcast_to(@post, @comment)
  42. #
  43. # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can also supply a callback that lets you alter what is sent out.
  44. # The below example shows how you can use this to provide performance introspection in the process:
  45. #
  46. # class ChatChannel < ApplicationCable::Channel
  47. # def subscribed
  48. # @room = Chat::Room[params[:room_number]]
  49. #
  50. # stream_for @room, coder: ActiveSupport::JSON do |message|
  51. # if message['originated_at'].present?
  52. # elapsed_time = (Time.now.to_f - message['originated_at']).round(2)
  53. #
  54. # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing
  55. # logger.info "Message took #{elapsed_time}s to arrive"
  56. # end
  57. #
  58. # transmit message
  59. # end
  60. # end
  61. # end
  62. #
  63. # You can stop streaming from all broadcasts by calling #stop_all_streams.
  64. 1 module Streams
  65. 1 extend ActiveSupport::Concern
  66. 1 included do
  67. 1 on_unsubscribe :stop_all_streams
  68. end
  69. # Start streaming from the named <tt>broadcasting</tt> pubsub queue. Optionally, you can pass a <tt>callback</tt> that'll be used
  70. # instead of the default of just transmitting the updates straight to the subscriber.
  71. # Pass <tt>coder: ActiveSupport::JSON</tt> to decode messages as JSON before passing to the callback.
  72. # Defaults to <tt>coder: nil</tt> which does no decoding, passes raw messages.
  73. 1 def stream_from(broadcasting, callback = nil, coder: nil, &block)
  74. 134 broadcasting = String(broadcasting)
  75. # Don't send the confirmation until pubsub#subscribe is successful
  76. 134 defer_subscription_confirmation!
  77. # Build a stream handler by wrapping the user-provided callback with
  78. # a decoder or defaulting to a JSON-decoding retransmitter.
  79. 134 handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
  80. 134 streams[broadcasting] = handler
  81. 134 connection.server.event_loop.post do
  82. 134 pubsub.subscribe(broadcasting, handler, lambda do
  83. 119 ensure_confirmation_sent
  84. 119 logger.info "#{self.class.name} is streaming from #{broadcasting}"
  85. end)
  86. end
  87. end
  88. # Start streaming the pubsub queue for the <tt>model</tt> in this channel. Optionally, you can pass a
  89. # <tt>callback</tt> that'll be used instead of the default of just transmitting the updates straight
  90. # to the subscriber.
  91. #
  92. # Pass <tt>coder: ActiveSupport::JSON</tt> to decode messages as JSON before passing to the callback.
  93. # Defaults to <tt>coder: nil</tt> which does no decoding, passes raw messages.
  94. 1 def stream_for(model, callback = nil, coder: nil, &block)
  95. 6 stream_from(broadcasting_for(model), callback || block, coder: coder)
  96. end
  97. # Unsubscribes streams from the named <tt>broadcasting</tt>.
  98. 1 def stop_stream_from(broadcasting)
  99. 2 callback = streams.delete(broadcasting)
  100. 2 if callback
  101. 2 pubsub.unsubscribe(broadcasting, callback)
  102. 2 logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
  103. end
  104. end
  105. # Unsubscribes streams for the <tt>model</tt>.
  106. 1 def stop_stream_for(model)
  107. 1 stop_stream_from(broadcasting_for(model))
  108. end
  109. # Unsubscribes all streams associated with this channel from the pubsub queue.
  110. 1 def stop_all_streams
  111. streams.each do |broadcasting, callback|
  112. 118 pubsub.unsubscribe broadcasting, callback
  113. 118 logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
  114. 119 end.clear
  115. end
  116. # Calls stream_for if record is present, otherwise calls reject.
  117. # This method is intended to be called when you're looking
  118. # for a record based on a parameter, if its found it will start
  119. # streaming. If the record is nil then it will reject the connection.
  120. 1 def stream_or_reject_for(record)
  121. 2 if record
  122. 1 stream_for record
  123. else
  124. 1 reject
  125. end
  126. end
  127. 1 private
  128. 1 delegate :pubsub, to: :connection
  129. 1 def streams
  130. 255 @_streams ||= {}
  131. end
  132. # Always wrap the outermost handler to invoke the user handler on the
  133. # worker pool rather than blocking the event loop.
  134. 1 def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
  135. 134 handler = stream_handler(broadcasting, user_handler, coder: coder)
  136. 134 -> message do
  137. 102 connection.worker_pool.async_invoke handler, :call, message, connection: connection
  138. end
  139. end
  140. # May be overridden to add instrumentation, logging, specialized error
  141. # handling, or other forms of handler decoration.
  142. #
  143. # TODO: Tests demonstrating this.
  144. 1 def stream_handler(broadcasting, user_handler, coder: nil)
  145. 134 if user_handler
  146. 1 stream_decoder user_handler, coder: coder
  147. else
  148. 133 default_stream_handler broadcasting, coder: coder
  149. end
  150. end
  151. # May be overridden to change the default stream handling behavior
  152. # which decodes JSON and transmits to the client.
  153. #
  154. # TODO: Tests demonstrating this.
  155. #
  156. # TODO: Room for optimization. Update transmit API to be coder-aware
  157. # so we can no-op when pubsub and connection are both JSON-encoded.
  158. # Then we can skip decode+encode if we're just proxying messages.
  159. 1 def default_stream_handler(broadcasting, coder:)
  160. 133 coder ||= ActiveSupport::JSON
  161. 133 stream_transmitter stream_decoder(coder: coder), broadcasting: broadcasting
  162. end
  163. 1 def stream_decoder(handler = identity_handler, coder:)
  164. 134 if coder
  165. 234 -> message { handler.(coder.decode(message)) }
  166. else
  167. 1 handler
  168. end
  169. end
  170. 1 def stream_transmitter(handler = identity_handler, broadcasting:)
  171. 133 via = "streamed from #{broadcasting}"
  172. 133 -> (message) do
  173. 101 transmit handler.(message), via: via
  174. end
  175. end
  176. 1 def identity_handler
  177. 234 -> message { message }
  178. end
  179. end
  180. end
  181. end

lib/action_cable/channel/test_case.rb

95.6% lines covered

91 relevant lines. 87 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support"
  3. 1 require "active_support/test_case"
  4. 1 require "active_support/core_ext/hash/indifferent_access"
  5. 1 require "json"
  6. 1 module ActionCable
  7. 1 module Channel
  8. 1 class NonInferrableChannelError < ::StandardError
  9. 1 def initialize(name)
  10. super "Unable to determine the channel to test from #{name}. " +
  11. "You'll need to specify it using `tests YourChannel` in your " +
  12. "test case definition."
  13. end
  14. end
  15. # Stub `stream_from` to track streams for the channel.
  16. # Add public aliases for `subscription_confirmation_sent?` and
  17. # `subscription_rejected?`.
  18. 1 module ChannelStub
  19. 1 def confirmed?
  20. 2 subscription_confirmation_sent?
  21. end
  22. 1 def rejected?
  23. 7 subscription_rejected?
  24. end
  25. 1 def stream_from(broadcasting, *)
  26. 3 streams << broadcasting
  27. end
  28. 1 def stop_all_streams
  29. 1 @_streams = []
  30. end
  31. 1 def streams
  32. 8 @_streams ||= []
  33. end
  34. # Make periodic timers no-op
  35. 1 def start_periodic_timers; end
  36. 1 alias stop_periodic_timers start_periodic_timers
  37. end
  38. 1 class ConnectionStub
  39. 1 attr_reader :transmissions, :identifiers, :subscriptions, :logger
  40. 1 def initialize(identifiers = {})
  41. 13 @transmissions = []
  42. 13 identifiers.each do |identifier, val|
  43. 12 define_singleton_method(identifier) { val }
  44. end
  45. 13 @subscriptions = ActionCable::Connection::Subscriptions.new(self)
  46. 13 @identifiers = identifiers.keys
  47. 13 @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new)
  48. end
  49. 1 def transmit(cable_message)
  50. 14 transmissions << cable_message.with_indifferent_access
  51. end
  52. end
  53. # Superclass for Action Cable channel functional tests.
  54. #
  55. # == Basic example
  56. #
  57. # Functional tests are written as follows:
  58. # 1. First, one uses the +subscribe+ method to simulate subscription creation.
  59. # 2. Then, one asserts whether the current state is as expected. "State" can be anything:
  60. # transmitted messages, subscribed streams, etc.
  61. #
  62. # For example:
  63. #
  64. # class ChatChannelTest < ActionCable::Channel::TestCase
  65. # def test_subscribed_with_room_number
  66. # # Simulate a subscription creation
  67. # subscribe room_number: 1
  68. #
  69. # # Asserts that the subscription was successfully created
  70. # assert subscription.confirmed?
  71. #
  72. # # Asserts that the channel subscribes connection to a stream
  73. # assert_has_stream "chat_1"
  74. #
  75. # # Asserts that the channel subscribes connection to a specific
  76. # # stream created for a model
  77. # assert_has_stream_for Room.find(1)
  78. # end
  79. #
  80. # def test_does_not_stream_with_incorrect_room_number
  81. # subscribe room_number: -1
  82. #
  83. # # Asserts that not streams was started
  84. # assert_no_streams
  85. # end
  86. #
  87. # def test_does_not_subscribe_without_room_number
  88. # subscribe
  89. #
  90. # # Asserts that the subscription was rejected
  91. # assert subscription.rejected?
  92. # end
  93. # end
  94. #
  95. # You can also perform actions:
  96. # def test_perform_speak
  97. # subscribe room_number: 1
  98. #
  99. # perform :speak, message: "Hello, Rails!"
  100. #
  101. # assert_equal "Hello, Rails!", transmissions.last["text"]
  102. # end
  103. #
  104. # == Special methods
  105. #
  106. # ActionCable::Channel::TestCase will also automatically provide the following instance
  107. # methods for use in the tests:
  108. #
  109. # <b>connection</b>::
  110. # An ActionCable::Channel::ConnectionStub, representing the current HTTP connection.
  111. # <b>subscription</b>::
  112. # An instance of the current channel, created when you call `subscribe`.
  113. # <b>transmissions</b>::
  114. # A list of all messages that have been transmitted into the channel.
  115. #
  116. #
  117. # == Channel is automatically inferred
  118. #
  119. # ActionCable::Channel::TestCase will automatically infer the channel under test
  120. # from the test class name. If the channel cannot be inferred from the test
  121. # class name, you can explicitly set it with +tests+.
  122. #
  123. # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
  124. # tests SpecialChannel
  125. # end
  126. #
  127. # == Specifying connection identifiers
  128. #
  129. # You need to set up your connection manually to provide values for the identifiers.
  130. # To do this just use:
  131. #
  132. # stub_connection(user: users(:john))
  133. #
  134. # == Testing broadcasting
  135. #
  136. # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g.
  137. # +assert_broadcasts+) to handle broadcasting to models:
  138. #
  139. #
  140. # # in your channel
  141. # def speak(data)
  142. # broadcast_to room, text: data["message"]
  143. # end
  144. #
  145. # def test_speak
  146. # subscribe room_id: rooms(:chat).id
  147. #
  148. # assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do
  149. # perform :speak, message: "Hello, Rails!"
  150. # end
  151. # end
  152. 1 class TestCase < ActiveSupport::TestCase
  153. 1 module Behavior
  154. 1 extend ActiveSupport::Concern
  155. 1 include ActiveSupport::Testing::ConstantLookup
  156. 1 include ActionCable::TestHelper
  157. 1 CHANNEL_IDENTIFIER = "test_stub"
  158. 1 included do
  159. 1 class_attribute :_channel_class
  160. 1 attr_reader :connection, :subscription
  161. 1 ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self)
  162. end
  163. 1 module ClassMethods
  164. 1 def tests(channel)
  165. 12 case channel
  166. when String, Symbol
  167. 2 self._channel_class = channel.to_s.camelize.constantize
  168. when Module
  169. 10 self._channel_class = channel
  170. else
  171. raise NonInferrableChannelError.new(channel)
  172. end
  173. end
  174. 1 def channel_class
  175. 18 if channel = self._channel_class
  176. 11 channel
  177. else
  178. 7 tests determine_default_channel(name)
  179. end
  180. end
  181. 1 def determine_default_channel(name)
  182. 7 channel = determine_constant_from_test_name(name) do |constant|
  183. 7 Class === constant && constant < ActionCable::Channel::Base
  184. end
  185. 7 raise NonInferrableChannelError.new(name) if channel.nil?
  186. 7 channel
  187. end
  188. end
  189. # Set up test connection with the specified identifiers:
  190. #
  191. # class ApplicationCable < ActionCable::Connection::Base
  192. # identified_by :user, :token
  193. # end
  194. #
  195. # stub_connection(user: users[:john], token: 'my-secret-token')
  196. 1 def stub_connection(identifiers = {})
  197. 13 @connection = ConnectionStub.new(identifiers)
  198. end
  199. # Subscribe to the channel under test. Optionally pass subscription parameters as a Hash.
  200. 1 def subscribe(params = {})
  201. 12 @connection ||= stub_connection
  202. 12 @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access)
  203. 12 @subscription.singleton_class.include(ChannelStub)
  204. 12 @subscription.subscribe_to_channel
  205. 12 @subscription
  206. end
  207. # Unsubscribe the subscription under test.
  208. 1 def unsubscribe
  209. check_subscribed!
  210. subscription.unsubscribe_from_channel
  211. end
  212. # Perform action on a channel.
  213. #
  214. # NOTE: Must be subscribed.
  215. 1 def perform(action, data = {})
  216. 6 check_subscribed!
  217. 5 subscription.perform_action(data.stringify_keys.merge("action" => action.to_s))
  218. end
  219. # Returns messages transmitted into channel
  220. 1 def transmissions
  221. # Return only directly sent message (via #transmit)
  222. 6 connection.transmissions.map { |data| data["message"] }.compact
  223. end
  224. # Enhance TestHelper assertions to handle non-String
  225. # broadcastings
  226. 1 def assert_broadcasts(stream_or_object, *args)
  227. 1 super(broadcasting_for(stream_or_object), *args)
  228. end
  229. 1 def assert_broadcast_on(stream_or_object, *args)
  230. 2 super(broadcasting_for(stream_or_object), *args)
  231. end
  232. # Asserts that no streams have been started.
  233. #
  234. # def test_assert_no_started_stream
  235. # subscribe
  236. # assert_no_streams
  237. # end
  238. #
  239. 1 def assert_no_streams
  240. 1 assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found"
  241. end
  242. # Asserts that the specified stream has been started.
  243. #
  244. # def test_assert_started_stream
  245. # subscribe
  246. # assert_has_stream 'messages'
  247. # end
  248. #
  249. 1 def assert_has_stream(stream)
  250. 3 assert subscription.streams.include?(stream), "Stream #{stream} has not been started"
  251. end
  252. # Asserts that the specified stream for a model has started.
  253. #
  254. # def test_assert_started_stream_for
  255. # subscribe id: 42
  256. # assert_has_stream_for User.find(42)
  257. # end
  258. #
  259. 1 def assert_has_stream_for(object)
  260. 1 assert_has_stream(broadcasting_for(object))
  261. end
  262. 1 private
  263. 1 def check_subscribed!
  264. 6 raise "Must be subscribed!" if subscription.nil? || subscription.rejected?
  265. end
  266. 1 def broadcasting_for(stream_or_object)
  267. 4 return stream_or_object if stream_or_object.is_a?(String)
  268. 3 self.class.channel_class.broadcasting_for(stream_or_object)
  269. end
  270. end
  271. 1 include Behavior
  272. end
  273. end
  274. end

lib/action_cable/connection.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Connection
  4. 1 extend ActiveSupport::Autoload
  5. 1 eager_autoload do
  6. 1 autoload :Authorization
  7. 1 autoload :Base
  8. 1 autoload :ClientSocket
  9. 1 autoload :Identification
  10. 1 autoload :InternalChannel
  11. 1 autoload :MessageBuffer
  12. 1 autoload :Stream
  13. 1 autoload :StreamEventLoop
  14. 1 autoload :Subscriptions
  15. 1 autoload :TaggedLoggerProxy
  16. 1 autoload :TestCase
  17. 1 autoload :WebSocket
  18. end
  19. end
  20. end

lib/action_cable/connection/authorization.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Connection
  4. 1 module Authorization
  5. 1 class UnauthorizedError < StandardError; end
  6. # Closes the WebSocket connection if it is open and returns a 404 "File not Found" response.
  7. 1 def reject_unauthorized_connection
  8. 6 logger.error "An unauthorized connection attempt was rejected"
  9. 6 raise UnauthorizedError
  10. end
  11. end
  12. end
  13. end

lib/action_cable/connection/base.rb

98.18% lines covered

110 relevant lines. 108 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "action_dispatch"
  3. 1 require "active_support/rescuable"
  4. 1 module ActionCable
  5. 1 module Connection
  6. # For every WebSocket connection the Action Cable server accepts, a Connection object will be instantiated. This instance becomes the parent
  7. # of all of the channel subscriptions that are created from there on. Incoming messages are then routed to these channel subscriptions
  8. # based on an identifier sent by the Action Cable consumer. The Connection itself does not deal with any specific application logic beyond
  9. # authentication and authorization.
  10. #
  11. # Here's a basic example:
  12. #
  13. # module ApplicationCable
  14. # class Connection < ActionCable::Connection::Base
  15. # identified_by :current_user
  16. #
  17. # def connect
  18. # self.current_user = find_verified_user
  19. # logger.add_tags current_user.name
  20. # end
  21. #
  22. # def disconnect
  23. # # Any cleanup work needed when the cable connection is cut.
  24. # end
  25. #
  26. # private
  27. # def find_verified_user
  28. # User.find_by_identity(cookies.encrypted[:identity_id]) ||
  29. # reject_unauthorized_connection
  30. # end
  31. # end
  32. # end
  33. #
  34. # First, we declare that this connection can be identified by its current_user. This allows us to later be able to find all connections
  35. # established for that current_user (and potentially disconnect them). You can declare as many
  36. # identification indexes as you like. Declaring an identification means that an attr_accessor is automatically set for that key.
  37. #
  38. # Second, we rely on the fact that the WebSocket connection is established with the cookies from the domain being sent along. This makes
  39. # it easy to use signed cookies that were set when logging in via a web interface to authorize the WebSocket connection.
  40. #
  41. # Finally, we add a tag to the connection-specific logger with the name of the current user to easily distinguish their messages in the log.
  42. #
  43. # Pretty simple, eh?
  44. 1 class Base
  45. 1 include Identification
  46. 1 include InternalChannel
  47. 1 include Authorization
  48. 1 include ActiveSupport::Rescuable
  49. 1 attr_reader :server, :env, :subscriptions, :logger, :worker_pool, :protocol
  50. 1 delegate :event_loop, :pubsub, to: :server
  51. 1 def initialize(server, env, coder: ActiveSupport::JSON)
  52. 160 @server, @env, @coder = server, env, coder
  53. 160 @worker_pool = server.worker_pool
  54. 160 @logger = new_tagged_logger
  55. 160 @websocket = ActionCable::Connection::WebSocket.new(env, self, event_loop)
  56. 160 @subscriptions = ActionCable::Connection::Subscriptions.new(self)
  57. 160 @message_buffer = ActionCable::Connection::MessageBuffer.new(self)
  58. 160 @_internal_subscriptions = nil
  59. 160 @started_at = Time.now
  60. end
  61. # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
  62. # This method should not be called directly -- instead rely upon on the #connect (and #disconnect) callbacks.
  63. 1 def process #:nodoc:
  64. 153 logger.info started_request_message
  65. 153 if websocket.possible? && allow_request_origin?
  66. 138 respond_to_successful_request
  67. else
  68. 15 respond_to_invalid_request
  69. end
  70. end
  71. # Decodes WebSocket messages and dispatches them to subscribed channels.
  72. # WebSocket message transfer encoding is always JSON.
  73. 1 def receive(websocket_message) #:nodoc:
  74. 238 send_async :dispatch_websocket_message, websocket_message
  75. end
  76. 1 def dispatch_websocket_message(websocket_message) #:nodoc:
  77. 241 if websocket.alive?
  78. 241 subscriptions.execute_command decode(websocket_message)
  79. else
  80. logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})"
  81. end
  82. end
  83. 1 def transmit(cable_message) # :nodoc:
  84. 493 websocket.transmit encode(cable_message)
  85. end
  86. # Close the WebSocket connection.
  87. 1 def close(reason: nil, reconnect: true)
  88. 4 transmit(
  89. type: ActionCable::INTERNAL[:message_types][:disconnect],
  90. reason: reason,
  91. reconnect: reconnect
  92. )
  93. 4 websocket.close
  94. end
  95. # Invoke a method on the connection asynchronously through the pool of thread workers.
  96. 1 def send_async(method, *arguments)
  97. 467 worker_pool.async_invoke(self, method, *arguments)
  98. end
  99. # Return a basic hash of statistics for the connection keyed with <tt>identifier</tt>, <tt>started_at</tt>, <tt>subscriptions</tt>, and <tt>request_id</tt>.
  100. # This can be returned by a health check against the connection.
  101. 1 def statistics
  102. 1 {
  103. identifier: connection_identifier,
  104. started_at: @started_at,
  105. subscriptions: subscriptions.identifiers,
  106. request_id: @env["action_dispatch.request_id"]
  107. }
  108. end
  109. 1 def beat
  110. 3 transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i
  111. end
  112. 1 def on_open # :nodoc:
  113. 139 send_async :handle_open
  114. end
  115. 1 def on_message(message) # :nodoc:
  116. 238 message_buffer.append message
  117. end
  118. 1 def on_error(message) # :nodoc:
  119. # log errors to make diagnosing socket errors easier
  120. 40 logger.error "WebSocket error occurred: #{message}"
  121. end
  122. 1 def on_close(reason, code) # :nodoc:
  123. 115 send_async :handle_close
  124. end
  125. 1 private
  126. 1 attr_reader :websocket
  127. 1 attr_reader :message_buffer
  128. # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc.
  129. 1 def request # :doc:
  130. 723 @request ||= begin
  131. 153 environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application
  132. 153 ActionDispatch::Request.new(environment || env)
  133. end
  134. end
  135. # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks.
  136. 1 def cookies # :doc:
  137. 8 request.cookie_jar
  138. end
  139. 1 def encode(cable_message)
  140. 493 @coder.encode cable_message
  141. end
  142. 1 def decode(websocket_message)
  143. 241 @coder.decode websocket_message
  144. end
  145. 1 def handle_open
  146. 149 @protocol = websocket.protocol
  147. 149 connect if respond_to?(:connect)
  148. 148 subscribe_to_internal_channel
  149. 148 send_welcome_message
  150. 148 message_buffer.process!
  151. 148 server.add_connection(self)
  152. rescue ActionCable::Connection::Authorization::UnauthorizedError
  153. 1 close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false) if websocket.alive?
  154. end
  155. 1 def handle_close
  156. 117 logger.info finished_request_message
  157. 117 server.remove_connection(self)
  158. 117 subscriptions.unsubscribe_from_all
  159. 117 unsubscribe_from_internal_channel
  160. 117 disconnect if respond_to?(:disconnect)
  161. end
  162. 1 def send_welcome_message
  163. # Send welcome message to the internal connection monitor channel.
  164. # This ensures the connection monitor state is reset after a successful
  165. # websocket connection.
  166. 148 transmit type: ActionCable::INTERNAL[:message_types][:welcome]
  167. end
  168. 1 def allow_request_origin?
  169. 152 return true if server.config.disable_request_forgery_protection
  170. 35 proto = Rack::Request.new(env).ssl? ? "https" : "http"
  171. 35 if server.config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env['HTTP_HOST']}"
  172. 1 true
  173. 69 elsif Array(server.config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] }
  174. 20 true
  175. else
  176. 14 logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}")
  177. 14 false
  178. end
  179. end
  180. 1 def respond_to_successful_request
  181. 138 logger.info successful_request_message
  182. 138 websocket.rack_response
  183. end
  184. 1 def respond_to_invalid_request
  185. 15 close(reason: ActionCable::INTERNAL[:disconnect_reasons][:invalid_request]) if websocket.alive?
  186. 15 logger.error invalid_request_message
  187. 15 logger.info finished_request_message
  188. 15 [ 404, { "Content-Type" => "text/plain" }, [ "Page not found" ] ]
  189. end
  190. # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags.
  191. 1 def new_tagged_logger
  192. 160 TaggedLoggerProxy.new server.logger,
  193. tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize }
  194. end
  195. 1 def started_request_message
  196. 153 'Started %s "%s"%s for %s at %s' % [
  197. request.request_method,
  198. request.filtered_path,
  199. 153 websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
  200. request.ip,
  201. Time.now.to_s ]
  202. end
  203. 1 def finished_request_message
  204. 132 'Finished "%s"%s for %s at %s' % [
  205. request.filtered_path,
  206. 132 websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
  207. request.ip,
  208. Time.now.to_s ]
  209. end
  210. 1 def invalid_request_message
  211. 15 "Failed to upgrade to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
  212. env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
  213. ]
  214. end
  215. 1 def successful_request_message
  216. 138 "Successfully upgraded to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
  217. env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
  218. ]
  219. end
  220. end
  221. end
  222. end
  223. 1 ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base)

lib/action_cable/connection/client_socket.rb

93.26% lines covered

89 relevant lines. 83 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "websocket/driver"
  3. 1 module ActionCable
  4. 1 module Connection
  5. #--
  6. # This class is heavily based on faye-websocket-ruby
  7. #
  8. # Copyright (c) 2010-2015 James Coglan
  9. 1 class ClientSocket # :nodoc:
  10. 1 def self.determine_url(env)
  11. 159 scheme = secure_request?(env) ? "wss:" : "ws:"
  12. 159 "#{ scheme }//#{ env['HTTP_HOST'] }#{ env['REQUEST_URI'] }"
  13. end
  14. 1 def self.secure_request?(env)
  15. 159 return true if env["HTTPS"] == "on"
  16. 159 return true if env["HTTP_X_FORWARDED_SSL"] == "on"
  17. 159 return true if env["HTTP_X_FORWARDED_SCHEME"] == "https"
  18. 159 return true if env["HTTP_X_FORWARDED_PROTO"] == "https"
  19. 159 return true if env["rack.url_scheme"] == "https"
  20. 159 false
  21. end
  22. 1 CONNECTING = 0
  23. 1 OPEN = 1
  24. 1 CLOSING = 2
  25. 1 CLOSED = 3
  26. 1 attr_reader :env, :url
  27. 1 def initialize(env, event_target, event_loop, protocols)
  28. 159 @env = env
  29. 159 @event_target = event_target
  30. 159 @event_loop = event_loop
  31. 159 @url = ClientSocket.determine_url(@env)
  32. 159 @driver = @driver_started = nil
  33. 159 @close_params = ["", 1006]
  34. 159 @ready_state = CONNECTING
  35. # The driver calls +env+, +url+, and +write+
  36. 159 @driver = ::WebSocket::Driver.rack(self, protocols: protocols)
  37. 297 @driver.on(:open) { |e| open }
  38. 397 @driver.on(:message) { |e| receive_message(e.data) }
  39. 274 @driver.on(:close) { |e| begin_close(e.reason, e.code) }
  40. 159 @driver.on(:error) { |e| emit_error(e.message) }
  41. 159 @stream = ActionCable::Connection::Stream.new(@event_loop, self)
  42. end
  43. 1 def start_driver
  44. 138 return if @driver.nil? || @driver_started
  45. 138 @stream.hijack_rack_socket
  46. 138 if callback = @env["async.callback"]
  47. callback.call([101, {}, @stream])
  48. end
  49. 138 @driver_started = true
  50. 138 @driver.start
  51. end
  52. 1 def rack_response
  53. 138 start_driver
  54. 138 [ -1, {}, [] ]
  55. end
  56. 1 def write(data)
  57. 729 @stream.write(data)
  58. rescue => e
  59. 41 emit_error e.message
  60. end
  61. 1 def transmit(message)
  62. 489 return false if @ready_state > OPEN
  63. 485 case message
  64. when Numeric then @driver.text(message.to_s)
  65. 485 when String then @driver.text(message)
  66. when Array then @driver.binary(message)
  67. else false
  68. end
  69. end
  70. 1 def close(code = nil, reason = nil)
  71. 2 code ||= 1000
  72. 2 reason ||= ""
  73. 2 unless code == 1000 || (code >= 3000 && code <= 4999)
  74. raise ArgumentError, "Failed to execute 'close' on WebSocket: " \
  75. "The code must be either 1000, or between 3000 and 4999. " \
  76. "#{code} is neither."
  77. end
  78. 2 @ready_state = CLOSING unless @ready_state == CLOSED
  79. 2 @driver.close(reason, code)
  80. end
  81. 1 def parse(data)
  82. 352 @driver.parse(data)
  83. end
  84. 1 def client_gone
  85. finalize_close
  86. end
  87. 1 def alive?
  88. 260 @ready_state == OPEN
  89. end
  90. 1 def protocol
  91. 149 @driver.protocol
  92. end
  93. 1 private
  94. 1 def open
  95. 138 return unless @ready_state == CONNECTING
  96. 138 @ready_state = OPEN
  97. 138 @event_target.on_open
  98. end
  99. 1 def receive_message(data)
  100. 238 return unless @ready_state == OPEN
  101. 238 @event_target.on_message(data)
  102. end
  103. 1 def emit_error(message)
  104. 41 return if @ready_state >= CLOSING
  105. 41 @event_target.on_error(message)
  106. end
  107. 1 def begin_close(reason, code)
  108. 115 return if @ready_state == CLOSED
  109. 115 @ready_state = CLOSING
  110. 115 @close_params = [reason, code]
  111. 115 @stream.shutdown if @stream
  112. 115 finalize_close
  113. end
  114. 1 def finalize_close
  115. 115 return if @ready_state == CLOSED
  116. 115 @ready_state = CLOSED
  117. 115 @event_target.on_close(*@close_params)
  118. end
  119. end
  120. end
  121. end

lib/action_cable/connection/identification.rb

100.0% lines covered

21 relevant lines. 21 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "set"
  3. 1 module ActionCable
  4. 1 module Connection
  5. 1 module Identification
  6. 1 extend ActiveSupport::Concern
  7. 1 included do
  8. 2 class_attribute :identifiers, default: Set.new
  9. end
  10. 1 module ClassMethods
  11. # Mark a key as being a connection identifier index that can then be used to find the specific connection again later.
  12. # Common identifiers are current_user and current_account, but could be anything, really.
  13. #
  14. # Note that anything marked as an identifier will automatically create a delegate by the same name on any
  15. # channel instances created off the connection.
  16. 1 def identified_by(*identifiers)
  17. 19 Array(identifiers).each { |identifier| attr_accessor identifier }
  18. 9 self.identifiers += identifiers
  19. end
  20. end
  21. # Return a single connection identifier that combines the value of all the registered identifiers into a single gid.
  22. 1 def connection_identifier
  23. 171 unless defined? @connection_identifier
  24. 150 @connection_identifier = connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact
  25. end
  26. 171 @connection_identifier
  27. end
  28. 1 private
  29. 1 def connection_gid(ids)
  30. ids.map do |o|
  31. 7 if o.respond_to? :to_gid_param
  32. 6 o.to_gid_param
  33. else
  34. 1 o.to_s
  35. end
  36. 143 end.sort.join(":")
  37. end
  38. end
  39. end
  40. end

lib/action_cable/connection/internal_channel.rb

87.5% lines covered

24 relevant lines. 21 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Connection
  4. # Makes it possible for the RemoteConnection to disconnect a specific connection.
  5. 1 module InternalChannel
  6. 1 extend ActiveSupport::Concern
  7. 1 private
  8. 1 def internal_channel
  9. 12 "action_cable/#{connection_identifier}"
  10. end
  11. 1 def subscribe_to_internal_channel
  12. 148 if connection_identifier.present?
  13. 6 callback = -> (message) { process_internal_message decode(message) }
  14. 6 @_internal_subscriptions ||= []
  15. 6 @_internal_subscriptions << [ internal_channel, callback ]
  16. 12 server.event_loop.post { pubsub.subscribe(internal_channel, callback) }
  17. 6 logger.info "Registered connection (#{connection_identifier})"
  18. end
  19. end
  20. 1 def unsubscribe_from_internal_channel
  21. 117 if @_internal_subscriptions.present?
  22. 3 @_internal_subscriptions.each { |channel, callback| server.event_loop.post { pubsub.unsubscribe(channel, callback) } }
  23. end
  24. end
  25. 1 def process_internal_message(message)
  26. 2 case message["type"]
  27. when "disconnect"
  28. 1 logger.info "Removing connection (#{connection_identifier})"
  29. 1 websocket.close
  30. end
  31. rescue Exception => e
  32. logger.error "There was an exception - #{e.class}(#{e.message})"
  33. logger.error e.backtrace.join("\n")
  34. close
  35. end
  36. end
  37. end
  38. end

lib/action_cable/connection/message_buffer.rb

89.29% lines covered

28 relevant lines. 25 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Connection
  4. # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized, and is ready to receive them.
  5. 1 class MessageBuffer # :nodoc:
  6. 1 def initialize(connection)
  7. 160 @connection = connection
  8. 160 @buffered_messages = []
  9. end
  10. 1 def append(message)
  11. 238 if valid? message
  12. 238 if processing?
  13. 238 receive message
  14. else
  15. buffer message
  16. end
  17. else
  18. connection.logger.error "Couldn't handle non-string message: #{message.class}"
  19. end
  20. end
  21. 1 def processing?
  22. 238 @processing
  23. end
  24. 1 def process!
  25. 147 @processing = true
  26. 147 receive_buffered_messages
  27. end
  28. 1 private
  29. 1 attr_reader :connection
  30. 1 attr_reader :buffered_messages
  31. 1 def valid?(message)
  32. 238 message.is_a?(String)
  33. end
  34. 1 def receive(message)
  35. 238 connection.receive message
  36. end
  37. 1 def buffer(message)
  38. buffered_messages << message
  39. end
  40. 1 def receive_buffered_messages
  41. 147 receive buffered_messages.shift until buffered_messages.empty?
  42. end
  43. end
  44. end
  45. end

lib/action_cable/connection/stream.rb

65.0% lines covered

60 relevant lines. 39 lines covered and 21 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "thread"
  3. 1 module ActionCable
  4. 1 module Connection
  5. #--
  6. # This class is heavily based on faye-websocket-ruby
  7. #
  8. # Copyright (c) 2010-2015 James Coglan
  9. 1 class Stream # :nodoc:
  10. 1 def initialize(event_loop, socket)
  11. 159 @event_loop = event_loop
  12. 159 @socket_object = socket
  13. 159 @stream_send = socket.env["stream.send"]
  14. 159 @rack_hijack_io = nil
  15. 159 @write_lock = Mutex.new
  16. 159 @write_head = nil
  17. 159 @write_buffer = Queue.new
  18. end
  19. 1 def each(&callback)
  20. @stream_send ||= callback
  21. end
  22. 1 def close
  23. shutdown
  24. @socket_object.client_gone
  25. end
  26. 1 def shutdown
  27. 115 clean_rack_hijack
  28. end
  29. 1 def write(data)
  30. 728 if @stream_send
  31. return @stream_send.call(data)
  32. end
  33. 728 if @write_lock.try_lock
  34. 728 begin
  35. 728 if @write_head.nil? && @write_buffer.empty?
  36. 728 written = @rack_hijack_io.write_nonblock(data, exception: false)
  37. 686 case written
  38. when :wait_writable
  39. # proceed below
  40. when data.bytesize
  41. 686 return data.bytesize
  42. else
  43. @write_head = data.byteslice(written, data.bytesize)
  44. @event_loop.writes_pending @rack_hijack_io
  45. return data.bytesize
  46. end
  47. end
  48. ensure
  49. 728 @write_lock.unlock
  50. end
  51. end
  52. @write_buffer << data
  53. @event_loop.writes_pending @rack_hijack_io
  54. data.bytesize
  55. rescue EOFError, Errno::ECONNRESET
  56. 2 @socket_object.client_gone
  57. end
  58. 1 def flush_write_buffer
  59. @write_lock.synchronize do
  60. loop do
  61. if @write_head.nil?
  62. return true if @write_buffer.empty?
  63. @write_head = @write_buffer.pop
  64. end
  65. written = @rack_hijack_io.write_nonblock(@write_head, exception: false)
  66. case written
  67. when :wait_writable
  68. return false
  69. when @write_head.bytesize
  70. @write_head = nil
  71. else
  72. @write_head = @write_head.byteslice(written, @write_head.bytesize)
  73. return false
  74. end
  75. end
  76. end
  77. end
  78. 1 def receive(data)
  79. 352 @socket_object.parse(data)
  80. end
  81. 1 def hijack_rack_socket
  82. 138 return unless @socket_object.env["rack.hijack"]
  83. # This should return the underlying io according to the SPEC:
  84. 119 @rack_hijack_io = @socket_object.env["rack.hijack"].call
  85. # Retain existing behaviour if required:
  86. 119 @rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
  87. 119 @event_loop.attach(@rack_hijack_io, self)
  88. end
  89. 1 private
  90. 1 def clean_rack_hijack
  91. 115 return unless @rack_hijack_io
  92. 115 @event_loop.detach(@rack_hijack_io, self)
  93. 115 @rack_hijack_io = nil
  94. end
  95. end
  96. end
  97. end

lib/action_cable/connection/stream_event_loop.rb

76.39% lines covered

72 relevant lines. 55 lines covered and 17 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "nio"
  3. 1 require "thread"
  4. 1 module ActionCable
  5. 1 module Connection
  6. 1 class StreamEventLoop
  7. 1 def initialize
  8. 100 @nio = @executor = @thread = nil
  9. 100 @map = {}
  10. 100 @stopping = false
  11. 100 @todo = Queue.new
  12. 100 @spawn_mutex = Mutex.new
  13. end
  14. 1 def timer(interval, &block)
  15. 6 Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute)
  16. end
  17. 1 def post(task = nil, &block)
  18. 501 task ||= block
  19. 501 spawn
  20. 501 @executor << task
  21. end
  22. 1 def attach(io, stream)
  23. 119 @todo << lambda do
  24. 119 @map[io] = @nio.register(io, :r)
  25. 117 @map[io].value = stream
  26. end
  27. 119 wakeup
  28. end
  29. 1 def detach(io, stream)
  30. 115 @todo << lambda do
  31. 115 @nio.deregister io
  32. 115 @map.delete io
  33. 115 io.close
  34. end
  35. 115 wakeup
  36. end
  37. 1 def writes_pending(io)
  38. @todo << lambda do
  39. if monitor = @map[io]
  40. monitor.interests = :rw
  41. end
  42. end
  43. wakeup
  44. end
  45. 1 def stop
  46. @stopping = true
  47. wakeup if @nio
  48. end
  49. 1 private
  50. 1 def spawn
  51. 735 return if @thread && @thread.status
  52. 74 @spawn_mutex.synchronize do
  53. 74 return if @thread && @thread.status
  54. 74 @nio ||= NIO::Selector.new
  55. 74 @executor ||= Concurrent::ThreadPoolExecutor.new(
  56. min_threads: 1,
  57. max_threads: 10,
  58. max_queue: 0,
  59. )
  60. 148 @thread = Thread.new { run }
  61. 74 return true
  62. end
  63. end
  64. 1 def wakeup
  65. 234 spawn || @nio.wakeup
  66. end
  67. 1 def run
  68. 74 loop do
  69. 192 if @stopping
  70. @nio.close
  71. break
  72. end
  73. 192 until @todo.empty?
  74. 234 @todo.pop(true).call
  75. end
  76. 190 next unless monitors = @nio.select
  77. 102 monitors.each do |monitor|
  78. 352 io = monitor.io
  79. 352 stream = monitor.value
  80. 352 begin
  81. 352 if monitor.writable?
  82. if stream.flush_write_buffer
  83. monitor.interests = :r
  84. end
  85. next unless monitor.readable?
  86. end
  87. 352 incoming = io.read_nonblock(4096, exception: false)
  88. 352 case incoming
  89. when :wait_readable
  90. next
  91. when nil
  92. stream.close
  93. else
  94. 352 stream.receive incoming
  95. end
  96. rescue
  97. # We expect one of EOFError or Errno::ECONNRESET in
  98. # normal operation (when the client goes away). But if
  99. # anything else goes wrong, this is still the best way
  100. # to handle it.
  101. begin
  102. stream.close
  103. rescue
  104. @nio.deregister io
  105. @map.delete io
  106. end
  107. end
  108. end
  109. end
  110. end
  111. end
  112. end
  113. end

lib/action_cable/connection/subscriptions.rb

95.45% lines covered

44 relevant lines. 42 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/core_ext/hash/indifferent_access"
  3. 1 module ActionCable
  4. 1 module Connection
  5. # Collection class for all the channel subscriptions established on a given connection. Responsible for routing incoming commands that arrive on
  6. # the connection to the proper channel.
  7. 1 class Subscriptions # :nodoc:
  8. 1 def initialize(connection)
  9. 180 @connection = connection
  10. 180 @subscriptions = {}
  11. end
  12. 1 def execute_command(data)
  13. 253 case data["command"]
  14. 126 when "subscribe" then add data
  15. 2 when "unsubscribe" then remove data
  16. 125 when "message" then perform_action data
  17. else
  18. logger.error "Received unrecognized command in #{data.inspect}"
  19. end
  20. rescue Exception => e
  21. 3 @connection.rescue_with_handler(e)
  22. 3 logger.error "Could not execute command from (#{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}"
  23. end
  24. 1 def add(data)
  25. 126 id_key = data["identifier"]
  26. 126 id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
  27. 125 return if subscriptions.key?(id_key)
  28. 124 subscription_klass = id_options[:channel].safe_constantize
  29. 124 if subscription_klass && ActionCable::Channel::Base >= subscription_klass
  30. 124 subscription = subscription_klass.new(connection, id_key, id_options)
  31. 124 subscriptions[id_key] = subscription
  32. 124 subscription.subscribe_to_channel
  33. else
  34. logger.error "Subscription class not found: #{id_options[:channel].inspect}"
  35. end
  36. end
  37. 1 def remove(data)
  38. 2 logger.info "Unsubscribing from channel: #{data['identifier']}"
  39. 2 remove_subscription find(data)
  40. end
  41. 1 def remove_subscription(subscription)
  42. 118 subscription.unsubscribe_from_channel
  43. 118 subscriptions.delete(subscription.identifier)
  44. end
  45. 1 def perform_action(data)
  46. 125 find(data).perform_action ActiveSupport::JSON.decode(data["data"])
  47. end
  48. 1 def identifiers
  49. 11 subscriptions.keys
  50. end
  51. 1 def unsubscribe_from_all
  52. 233 subscriptions.each { |id, channel| remove_subscription(channel) }
  53. end
  54. 1 private
  55. 1 attr_reader :connection, :subscriptions
  56. 1 delegate :logger, to: :connection
  57. 1 def find(data)
  58. 134 if subscription = subscriptions[data["identifier"]]
  59. 133 subscription
  60. else
  61. 1 raise "Unable to find subscription with identifier: #{data['identifier']}"
  62. end
  63. end
  64. end
  65. end
  66. end

lib/action_cable/connection/tagged_logger_proxy.rb

100.0% lines covered

21 relevant lines. 21 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Connection
  4. # Allows the use of per-connection tags against the server logger. This wouldn't work using the traditional
  5. # <tt>ActiveSupport::TaggedLogging</tt> enhanced Rails.logger, as that logger will reset the tags between requests.
  6. # The connection is long-lived, so it needs its own set of tags for its independent duration.
  7. 1 class TaggedLoggerProxy
  8. 1 attr_reader :tags
  9. 1 def initialize(logger, tags:)
  10. 176 @logger = logger
  11. 176 @tags = tags.flatten
  12. end
  13. 1 def add_tags(*tags)
  14. 1 @tags += tags.flatten
  15. 1 @tags = @tags.uniq
  16. end
  17. 1 def tag(logger)
  18. 1777 if logger.respond_to?(:tagged)
  19. 182 current_tags = tags - logger.formatter.current_tags
  20. 364 logger.tagged(*current_tags) { yield }
  21. else
  22. 1595 yield
  23. end
  24. end
  25. 1 %i( debug info warn error fatal unknown ).each do |severity|
  26. 6 define_method(severity) do |message|
  27. 1206 log severity, message
  28. end
  29. end
  30. 1 private
  31. 1 def log(type, message) # :doc:
  32. 2412 tag(@logger) { @logger.send type, message }
  33. end
  34. end
  35. end
  36. end

lib/action_cable/connection/test_case.rb

96.2% lines covered

79 relevant lines. 76 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support"
  3. 1 require "active_support/test_case"
  4. 1 require "active_support/core_ext/hash/indifferent_access"
  5. 1 require "action_dispatch"
  6. 1 require "action_dispatch/http/headers"
  7. 1 require "action_dispatch/testing/test_request"
  8. 1 module ActionCable
  9. 1 module Connection
  10. 1 class NonInferrableConnectionError < ::StandardError
  11. 1 def initialize(name)
  12. super "Unable to determine the connection to test from #{name}. " +
  13. "You'll need to specify it using `tests YourConnection` in your " +
  14. "test case definition."
  15. end
  16. end
  17. 1 module Assertions
  18. # Asserts that the connection is rejected (via +reject_unauthorized_connection+).
  19. #
  20. # # Asserts that connection without user_id fails
  21. # assert_reject_connection { connect params: { user_id: '' } }
  22. 1 def assert_reject_connection(&block)
  23. 6 assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block)
  24. end
  25. end
  26. # We don't want to use the whole "encryption stack" for connection
  27. # unit-tests, but we want to make sure that users test against the correct types
  28. # of cookies (i.e. signed or encrypted or plain)
  29. 1 class TestCookieJar < ActiveSupport::HashWithIndifferentAccess
  30. 1 def signed
  31. 4 self[:signed] ||= {}.with_indifferent_access
  32. end
  33. 1 def encrypted
  34. 3 self[:encrypted] ||= {}.with_indifferent_access
  35. end
  36. end
  37. 1 class TestRequest < ActionDispatch::TestRequest
  38. 1 attr_accessor :session, :cookie_jar
  39. end
  40. 1 module TestConnection
  41. 1 attr_reader :logger, :request
  42. 1 def initialize(request)
  43. 14 inner_logger = ActiveSupport::Logger.new(StringIO.new)
  44. 14 tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger)
  45. 14 @logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: [])
  46. 14 @request = request
  47. 14 @env = request.env
  48. end
  49. end
  50. # Unit test Action Cable connections.
  51. #
  52. # Useful to check whether a connection's +identified_by+ gets assigned properly
  53. # and that any improper connection requests are rejected.
  54. #
  55. # == Basic example
  56. #
  57. # Unit tests are written as follows:
  58. #
  59. # 1. Simulate a connection attempt by calling +connect+.
  60. # 2. Assert state, e.g. identifiers, has been assigned.
  61. #
  62. #
  63. # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
  64. # def test_connects_with_proper_cookie
  65. # # Simulate the connection request with a cookie.
  66. # cookies["user_id"] = users(:john).id
  67. #
  68. # connect
  69. #
  70. # # Assert the connection identifier matches the fixture.
  71. # assert_equal users(:john).id, connection.user.id
  72. # end
  73. #
  74. # def test_rejects_connection_without_proper_cookie
  75. # assert_reject_connection { connect }
  76. # end
  77. # end
  78. #
  79. # +connect+ accepts additional information about the HTTP request with the
  80. # +params+, +headers+, +session+ and Rack +env+ options.
  81. #
  82. # def test_connect_with_headers_and_query_string
  83. # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" }
  84. #
  85. # assert_equal "1", connection.user.id
  86. # assert_equal "secret-my", connection.token
  87. # end
  88. #
  89. # def test_connect_with_params
  90. # connect params: { user_id: 1 }
  91. #
  92. # assert_equal "1", connection.user.id
  93. # end
  94. #
  95. # You can also set up the correct cookies before the connection request:
  96. #
  97. # def test_connect_with_cookies
  98. # # Plain cookies:
  99. # cookies["user_id"] = 1
  100. #
  101. # # Or signed/encrypted:
  102. # # cookies.signed["user_id"] = 1
  103. # # cookies.encrypted["user_id"] = 1
  104. #
  105. # connect
  106. #
  107. # assert_equal "1", connection.user_id
  108. # end
  109. #
  110. # == Connection is automatically inferred
  111. #
  112. # ActionCable::Connection::TestCase will automatically infer the connection under test
  113. # from the test class name. If the channel cannot be inferred from the test
  114. # class name, you can explicitly set it with +tests+.
  115. #
  116. # class ConnectionTest < ActionCable::Connection::TestCase
  117. # tests ApplicationCable::Connection
  118. # end
  119. #
  120. 1 class TestCase < ActiveSupport::TestCase
  121. 1 module Behavior
  122. 1 extend ActiveSupport::Concern
  123. 1 DEFAULT_PATH = "/cable"
  124. 1 include ActiveSupport::Testing::ConstantLookup
  125. 1 include Assertions
  126. 1 included do
  127. 1 class_attribute :_connection_class
  128. 1 attr_reader :connection
  129. 1 ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self)
  130. end
  131. 1 module ClassMethods
  132. 1 def tests(connection)
  133. 5 case connection
  134. when String, Symbol
  135. self._connection_class = connection.to_s.camelize.constantize
  136. when Module
  137. 5 self._connection_class = connection
  138. else
  139. raise NonInferrableConnectionError.new(connection)
  140. end
  141. end
  142. 1 def connection_class
  143. 14 if connection = self._connection_class
  144. 13 connection
  145. else
  146. 1 tests determine_default_connection(name)
  147. end
  148. end
  149. 1 def determine_default_connection(name)
  150. 1 connection = determine_constant_from_test_name(name) do |constant|
  151. 1 Class === constant && constant < ActionCable::Connection::Base
  152. end
  153. 1 raise NonInferrableConnectionError.new(name) if connection.nil?
  154. 1 connection
  155. end
  156. end
  157. # Performs connection attempt to exert #connect on the connection under test.
  158. #
  159. # Accepts request path as the first argument and the following request options:
  160. #
  161. # - params – URL parameters (Hash)
  162. # - headers – request headers (Hash)
  163. # - session – session data (Hash)
  164. # - env – additional Rack env configuration (Hash)
  165. 1 def connect(path = ActionCable.server.config.mount_path, **request_params)
  166. 14 path ||= DEFAULT_PATH
  167. 14 connection = self.class.connection_class.allocate
  168. 14 connection.singleton_class.include(TestConnection)
  169. 14 connection.send(:initialize, build_test_request(path, **request_params))
  170. 14 connection.connect if connection.respond_to?(:connect)
  171. # Only set instance variable if connected successfully
  172. 9 @connection = connection
  173. end
  174. # Exert #disconnect on the connection under test.
  175. 1 def disconnect
  176. 1 raise "Must be connected!" if connection.nil?
  177. 1 connection.disconnect if connection.respond_to?(:disconnect)
  178. 1 @connection = nil
  179. end
  180. 1 def cookies
  181. 19 @cookie_jar ||= TestCookieJar.new
  182. end
  183. 1 private
  184. 1 def build_test_request(path, params: nil, headers: {}, session: {}, env: {})
  185. 14 wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers)
  186. 14 uri = URI.parse(path)
  187. 14 query_string = params.nil? ? uri.query : params.to_query
  188. 14 request_env = {
  189. "QUERY_STRING" => query_string,
  190. "PATH_INFO" => uri.path
  191. }.merge(env)
  192. 14 if wrapped_headers.present?
  193. 14 ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers)
  194. end
  195. 14 TestRequest.create(request_env).tap do |request|
  196. 14 request.session = session.with_indifferent_access
  197. 14 request.cookie_jar = cookies
  198. end
  199. end
  200. end
  201. 1 include Behavior
  202. end
  203. end
  204. end

lib/action_cable/connection/web_socket.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "websocket/driver"
  3. 1 module ActionCable
  4. 1 module Connection
  5. # Wrap the real socket to minimize the externally-presented API
  6. 1 class WebSocket # :nodoc:
  7. 1 def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols])
  8. 160 @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil
  9. end
  10. 1 def possible?
  11. 442 websocket
  12. end
  13. 1 def alive?
  14. 261 websocket && websocket.alive?
  15. end
  16. 1 def transmit(data)
  17. 489 websocket.transmit data
  18. end
  19. 1 def close
  20. 2 websocket.close
  21. end
  22. 1 def protocol
  23. 149 websocket.protocol
  24. end
  25. 1 def rack_response
  26. 138 websocket.rack_response
  27. end
  28. 1 private
  29. 1 attr_reader :websocket
  30. end
  31. end
  32. end

lib/action_cable/engine.rb

0.0% lines covered

61 relevant lines. 0 lines covered and 61 lines missed.
    
  1. # frozen_string_literal: true
  2. require "rails"
  3. require "action_cable"
  4. require "action_cable/helpers/action_cable_helper"
  5. require "active_support/core_ext/hash/indifferent_access"
  6. module ActionCable
  7. class Engine < Rails::Engine # :nodoc:
  8. config.action_cable = ActiveSupport::OrderedOptions.new
  9. config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path]
  10. config.eager_load_namespaces << ActionCable
  11. initializer "action_cable.helpers" do
  12. ActiveSupport.on_load(:action_view) do
  13. include ActionCable::Helpers::ActionCableHelper
  14. end
  15. end
  16. initializer "action_cable.logger" do
  17. ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger }
  18. end
  19. initializer "action_cable.set_configs" do |app|
  20. options = app.config.action_cable
  21. options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ if ::Rails.env.development?
  22. app.paths.add "config/cable", with: "config/cable.yml"
  23. ActiveSupport.on_load(:action_cable) do
  24. if (config_path = Pathname.new(app.config.paths["config/cable"].first)).exist?
  25. self.cable = Rails.application.config_for(config_path).with_indifferent_access
  26. end
  27. previous_connection_class = connection_class
  28. self.connection_class = -> { "ApplicationCable::Connection".safe_constantize || previous_connection_class.call }
  29. options.each { |k, v| send("#{k}=", v) }
  30. end
  31. end
  32. initializer "action_cable.routes" do
  33. config.after_initialize do |app|
  34. config = app.config
  35. unless config.action_cable.mount_path.nil?
  36. app.routes.prepend do
  37. mount ActionCable.server => config.action_cable.mount_path, internal: true
  38. end
  39. end
  40. end
  41. end
  42. initializer "action_cable.set_work_hooks" do |app|
  43. ActiveSupport.on_load(:action_cable) do
  44. ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner|
  45. app.executor.wrap do
  46. # If we took a while to get the lock, we may have been halted
  47. # in the meantime. As we haven't started doing any real work
  48. # yet, we should pretend that we never made it off the queue.
  49. unless stopping?
  50. inner.call
  51. end
  52. end
  53. end
  54. wrap = lambda do |_, inner|
  55. app.executor.wrap(&inner)
  56. end
  57. ActionCable::Channel::Base.set_callback :subscribe, :around, prepend: true, &wrap
  58. ActionCable::Channel::Base.set_callback :unsubscribe, :around, prepend: true, &wrap
  59. app.reloader.before_class_unload do
  60. ActionCable.server.restart
  61. end
  62. end
  63. end
  64. end
  65. end

lib/action_cable/gem_version.rb

88.89% lines covered

9 relevant lines. 8 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. # Returns the version of the currently loaded Action Cable as a <tt>Gem::Version</tt>.
  4. 1 def self.gem_version
  5. Gem::Version.new VERSION::STRING
  6. end
  7. 1 module VERSION
  8. 1 MAJOR = 6
  9. 1 MINOR = 1
  10. 1 TINY = 0
  11. 1 PRE = "alpha"
  12. 1 STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
  13. end
  14. end

lib/action_cable/helpers/action_cable_helper.rb

0.0% lines covered

13 relevant lines. 0 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. module ActionCable
  3. module Helpers
  4. module ActionCableHelper
  5. # Returns an "action-cable-url" meta tag with the value of the URL specified in your
  6. # configuration. Ensure this is above your JavaScript tag:
  7. #
  8. # <head>
  9. # <%= action_cable_meta_tag %>
  10. # <%= javascript_include_tag 'application', 'data-turbolinks-track' => 'reload' %>
  11. # </head>
  12. #
  13. # This is then used by Action Cable to determine the URL of your WebSocket server.
  14. # Your JavaScript can then connect to the server without needing to specify the
  15. # URL directly:
  16. #
  17. # window.Cable = require("@rails/actioncable")
  18. # window.App = {}
  19. # App.cable = Cable.createConsumer()
  20. #
  21. # Make sure to specify the correct server location in each of your environment
  22. # config files:
  23. #
  24. # config.action_cable.mount_path = "/cable123"
  25. # <%= action_cable_meta_tag %> would render:
  26. # => <meta name="action-cable-url" content="/cable123" />
  27. #
  28. # config.action_cable.url = "ws://actioncable.com"
  29. # <%= action_cable_meta_tag %> would render:
  30. # => <meta name="action-cable-url" content="ws://actioncable.com" />
  31. #
  32. def action_cable_meta_tag
  33. tag "meta", name: "action-cable-url", content: (
  34. ActionCable.server.config.url ||
  35. ActionCable.server.config.mount_path ||
  36. raise("No Action Cable URL configured -- please configure this at config.action_cable.url")
  37. )
  38. end
  39. end
  40. end
  41. end

lib/action_cable/remote_connections.rb

96.43% lines covered

28 relevant lines. 27 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/core_ext/module/redefine_method"
  3. 1 module ActionCable
  4. # If you need to disconnect a given connection, you can go through the
  5. # RemoteConnections. You can find the connections you're looking for by
  6. # searching for the identifier declared on the connection. For example:
  7. #
  8. # module ApplicationCable
  9. # class Connection < ActionCable::Connection::Base
  10. # identified_by :current_user
  11. # ....
  12. # end
  13. # end
  14. #
  15. # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect
  16. #
  17. # This will disconnect all the connections established for
  18. # <tt>User.find(1)</tt>, across all servers running on all machines, because
  19. # it uses the internal channel that all of these servers are subscribed to.
  20. 1 class RemoteConnections
  21. 1 attr_reader :server
  22. 1 def initialize(server)
  23. 1 @server = server
  24. end
  25. 1 def where(identifier)
  26. 1 RemoteConnection.new(server, identifier)
  27. end
  28. 1 private
  29. # Represents a single remote connection found via <tt>ActionCable.server.remote_connections.where(*)</tt>.
  30. # Exists solely for the purpose of calling #disconnect on that connection.
  31. 1 class RemoteConnection
  32. 1 class InvalidIdentifiersError < StandardError; end
  33. 1 include Connection::Identification, Connection::InternalChannel
  34. 1 def initialize(server, ids)
  35. 1 @server = server
  36. 1 set_identifier_instance_vars(ids)
  37. end
  38. # Uses the internal channel to disconnect the connection.
  39. 1 def disconnect
  40. server.broadcast internal_channel, type: "disconnect"
  41. end
  42. # Returns all the identifiers that were applied to this connection.
  43. 1 redefine_method :identifiers do
  44. 1 server.connection_identifiers
  45. end
  46. 1 protected
  47. 1 attr_reader :server
  48. 1 private
  49. 1 def set_identifier_instance_vars(ids)
  50. 1 raise InvalidIdentifiersError unless valid_identifiers?(ids)
  51. 2 ids.each { |k, v| instance_variable_set("@#{k}", v) }
  52. end
  53. 1 def valid_identifiers?(ids)
  54. 1 keys = ids.keys
  55. 1 identifiers.all? { |id| keys.include?(id) }
  56. end
  57. end
  58. end
  59. end

lib/action_cable/server.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Server
  4. 1 extend ActiveSupport::Autoload
  5. 1 eager_autoload do
  6. 1 autoload :Base
  7. 1 autoload :Broadcasting
  8. 1 autoload :Connections
  9. 1 autoload :Configuration
  10. 1 autoload :Worker
  11. end
  12. end
  13. end

lib/action_cable/server/base.rb

97.44% lines covered

39 relevant lines. 38 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "monitor"
  3. 1 module ActionCable
  4. 1 module Server
  5. # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the Rack process that starts the Action Cable server, but
  6. # is also used by the user to reach the RemoteConnections object, which is used for finding and disconnecting connections across all servers.
  7. #
  8. # Also, this is the server instance used for broadcasting. See Broadcasting for more information.
  9. 1 class Base
  10. 1 include ActionCable::Server::Broadcasting
  11. 1 include ActionCable::Server::Connections
  12. 1 cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new
  13. 1 attr_reader :config
  14. 1 def self.logger; config.logger; end
  15. 1 delegate :logger, to: :config
  16. 1 attr_reader :mutex
  17. 1 def initialize(config: self.class.config)
  18. 70 @config = config
  19. 70 @mutex = Monitor.new
  20. 70 @remote_connections = @event_loop = @worker_pool = @pubsub = nil
  21. end
  22. # Called by Rack to set up the server.
  23. 1 def call(env)
  24. 115 setup_heartbeat_timer
  25. 115 config.connection_class.call.new(self, env).process
  26. end
  27. # Disconnect all the connections identified by +identifiers+ on this server or any others via RemoteConnections.
  28. 1 def disconnect(identifiers)
  29. remote_connections.where(identifiers).disconnect
  30. end
  31. 1 def restart
  32. 4 connections.each do |connection|
  33. 2 connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart])
  34. end
  35. 4 @mutex.synchronize do
  36. # Shutdown the worker pool
  37. 4 @worker_pool.halt if @worker_pool
  38. 4 @worker_pool = nil
  39. # Shutdown the pub/sub adapter
  40. 4 @pubsub.shutdown if @pubsub
  41. 4 @pubsub = nil
  42. end
  43. end
  44. # Gateway to RemoteConnections. See that class for details.
  45. 1 def remote_connections
  46. 2 @remote_connections || @mutex.synchronize { @remote_connections ||= RemoteConnections.new(self) }
  47. end
  48. 1 def event_loop
  49. 380 @event_loop || @mutex.synchronize { @event_loop ||= ActionCable::Connection::StreamEventLoop.new }
  50. end
  51. # The worker pool is where we run connection callbacks and channel actions. We do as little as possible on the server's main thread.
  52. # The worker pool is an executor service that's backed by a pool of threads working from a task queue. The thread pool size maxes out
  53. # at 4 worker threads by default. Tune the size yourself with <tt>config.action_cable.worker_pool_size</tt>.
  54. #
  55. # Using Active Record, Redis, etc within your channel actions means you'll get a separate connection from each thread in the worker pool.
  56. # Plan your deployment accordingly: 5 servers each running 5 Puma workers each running an 8-thread worker pool means at least 200 database
  57. # connections.
  58. #
  59. # Also, ensure that your database connection pool size is as least as large as your worker pool size. Otherwise, workers may oversubscribe
  60. # the database connection pool and block while they wait for other workers to release their connections. Use a smaller worker pool or a larger
  61. # database connection pool instead.
  62. 1 def worker_pool
  63. 123 @worker_pool || @mutex.synchronize { @worker_pool ||= ActionCable::Server::Worker.new(max_size: config.worker_pool_size) }
  64. end
  65. # Adapter used for all streams/broadcasting.
  66. 1 def pubsub
  67. 501 @pubsub || @mutex.synchronize { @pubsub ||= config.pubsub_adapter.new(self) }
  68. end
  69. # All of the identifiers applied to the connection class associated with this server.
  70. 1 def connection_identifiers
  71. 1 config.connection_class.call.identifiers
  72. end
  73. end
  74. 1 ActiveSupport.run_load_hooks(:action_cable, Base.config)
  75. end
  76. end

lib/action_cable/server/broadcasting.rb

100.0% lines covered

18 relevant lines. 18 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Server
  4. # Broadcasting is how other parts of your application can send messages to a channel's subscribers. As explained in Channel, most of the time, these
  5. # broadcastings are streamed directly to the clients subscribed to the named broadcasting. Let's explain with a full-stack example:
  6. #
  7. # class WebNotificationsChannel < ApplicationCable::Channel
  8. # def subscribed
  9. # stream_from "web_notifications_#{current_user.id}"
  10. # end
  11. # end
  12. #
  13. # # Somewhere in your app this is called, perhaps from a NewCommentJob:
  14. # ActionCable.server.broadcast \
  15. # "web_notifications_1", { title: "New things!", body: "All that's fit for print" }
  16. #
  17. # # Client-side CoffeeScript, which assumes you've already requested the right to send web notifications:
  18. # App.cable.subscriptions.create "WebNotificationsChannel",
  19. # received: (data) ->
  20. # new Notification data['title'], body: data['body']
  21. 1 module Broadcasting
  22. # Broadcast a hash directly to a named <tt>broadcasting</tt>. This will later be JSON encoded.
  23. 1 def broadcast(broadcasting, message, coder: ActiveSupport::JSON)
  24. 31 broadcaster_for(broadcasting, coder: coder).broadcast(message)
  25. end
  26. # Returns a broadcaster for a named <tt>broadcasting</tt> that can be reused. Useful when you have an object that
  27. # may need multiple spots to transmit to a specific broadcasting over and over.
  28. 1 def broadcaster_for(broadcasting, coder: ActiveSupport::JSON)
  29. 33 Broadcaster.new(self, String(broadcasting), coder: coder)
  30. end
  31. 1 private
  32. 1 class Broadcaster
  33. 1 attr_reader :server, :broadcasting, :coder
  34. 1 def initialize(server, broadcasting, coder:)
  35. 33 @server, @broadcasting, @coder = server, broadcasting, coder
  36. end
  37. 1 def broadcast(message)
  38. 36 server.logger.debug { "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect}" }
  39. 32 payload = { broadcasting: broadcasting, message: message, coder: coder }
  40. 32 ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do
  41. 32 encoded = coder ? coder.encode(message) : message
  42. 32 server.pubsub.broadcast broadcasting, encoded
  43. end
  44. end
  45. end
  46. end
  47. end
  48. end

lib/action_cable/server/configuration.rb

87.5% lines covered

24 relevant lines. 21 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Server
  4. # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak Action Cable configuration
  5. # in a Rails config initializer.
  6. 1 class Configuration
  7. 1 attr_accessor :logger, :log_tags
  8. 1 attr_accessor :connection_class, :worker_pool_size
  9. 1 attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host
  10. 1 attr_accessor :cable, :url, :mount_path
  11. 1 def initialize
  12. 4 @log_tags = []
  13. 120 @connection_class = -> { ActionCable::Connection::Base }
  14. 4 @worker_pool_size = 4
  15. 4 @disable_request_forgery_protection = false
  16. 4 @allow_same_origin_as_host = true
  17. end
  18. # Returns constant of subscription adapter specified in config/cable.yml.
  19. # If the adapter cannot be found, this will default to the Redis adapter.
  20. # Also makes sure proper dependencies are required.
  21. 1 def pubsub_adapter
  22. 68 adapter = (cable.fetch("adapter") { "redis" })
  23. # Require the adapter itself and give useful feedback about
  24. # 1. Missing adapter gems and
  25. # 2. Adapter gems' missing dependencies.
  26. 68 path_to_adapter = "action_cable/subscription_adapter/#{adapter}"
  27. 68 begin
  28. 68 require path_to_adapter
  29. rescue LoadError => e
  30. # We couldn't require the adapter itself. Raise an exception that
  31. # points out config typos and missing gems.
  32. if e.path == path_to_adapter
  33. # We can assume that a non-builtin adapter was specified, so it's
  34. # either misspelled or missing from Gemfile.
  35. raise e.class, "Could not load the '#{adapter}' Action Cable pubsub adapter. Ensure that the adapter is spelled correctly in config/cable.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace
  36. # Bubbled up from the adapter require. Prefix the exception message
  37. # with some guidance about how to address it and reraise.
  38. else
  39. raise e.class, "Error loading the '#{adapter}' Action Cable pubsub adapter. Missing a gem it depends on? #{e.message}", e.backtrace
  40. end
  41. end
  42. 68 adapter = adapter.camelize
  43. 68 adapter = "PostgreSQL" if adapter == "Postgresql"
  44. 68 "ActionCable::SubscriptionAdapter::#{adapter}".constantize
  45. end
  46. end
  47. end
  48. end

lib/action_cable/server/connections.rb

93.33% lines covered

15 relevant lines. 14 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Server
  4. # Collection class for all the connections that have been established on this specific server. Remember, usually you'll run many Action Cable servers, so
  5. # you can't use this collection as a full list of all of the connections established against your application. Instead, use RemoteConnections for that.
  6. 1 module Connections # :nodoc:
  7. 1 BEAT_INTERVAL = 3
  8. 1 def connections
  9. 295 @connections ||= []
  10. end
  11. 1 def add_connection(connection)
  12. 149 connections << connection
  13. end
  14. 1 def remove_connection(connection)
  15. 117 connections.delete connection
  16. end
  17. # WebSocket connection implementations differ on when they'll mark a connection as stale. We basically never want a connection to go stale, as you
  18. # then can't rely on being able to communicate with the connection. To solve this, a 3 second heartbeat runs on all connections. If the beat fails, we automatically
  19. # disconnect.
  20. 1 def setup_heartbeat_timer
  21. 115 @heartbeat_timer ||= event_loop.timer(BEAT_INTERVAL) do
  22. 40 event_loop.post { connections.map(&:beat) }
  23. end
  24. end
  25. 1 def open_connections_statistics
  26. connections.map(&:statistics)
  27. end
  28. end
  29. end
  30. end

lib/action_cable/server/worker.rb

83.78% lines covered

37 relevant lines. 31 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/callbacks"
  3. 1 require "active_support/core_ext/module/attribute_accessors_per_thread"
  4. 1 require "action_cable/server/worker/active_record_connection_management"
  5. 1 require "concurrent"
  6. 1 module ActionCable
  7. 1 module Server
  8. # Worker used by Server.send_async to do connection work in threads.
  9. 1 class Worker # :nodoc:
  10. 1 include ActiveSupport::Callbacks
  11. 1 thread_mattr_accessor :connection
  12. 1 define_callbacks :work
  13. 1 include ActiveRecordConnectionManagement
  14. 1 attr_reader :executor
  15. 1 def initialize(max_size: 5)
  16. 44 @executor = Concurrent::ThreadPoolExecutor.new(
  17. min_threads: 1,
  18. max_threads: max_size,
  19. max_queue: 0,
  20. )
  21. end
  22. # Stop processing work: any work that has not already started
  23. # running will be discarded from the queue
  24. 1 def halt
  25. 1 @executor.shutdown
  26. end
  27. 1 def stopping?
  28. @executor.shuttingdown?
  29. end
  30. 1 def work(connection)
  31. 571 self.connection = connection
  32. 571 run_callbacks :work do
  33. 571 yield
  34. end
  35. ensure
  36. 571 self.connection = nil
  37. end
  38. 1 def async_exec(receiver, *args, connection:, &block)
  39. async_invoke receiver, :instance_exec, *args, connection: connection, &block
  40. end
  41. 1 def async_invoke(receiver, method, *args, connection: receiver, &block)
  42. 569 @executor.post do
  43. 569 invoke(receiver, method, *args, connection: connection, &block)
  44. end
  45. end
  46. 1 def invoke(receiver, method, *args, connection:, &block)
  47. 571 work(connection) do
  48. 571 receiver.send method, *args, &block
  49. rescue Exception => e
  50. logger.error "There was an exception - #{e.class}(#{e.message})"
  51. logger.error e.backtrace.join("\n")
  52. receiver.handle_exception if receiver.respond_to?(:handle_exception)
  53. end
  54. end
  55. 1 private
  56. 1 def logger
  57. ActionCable.server.logger
  58. end
  59. end
  60. end
  61. end

lib/action_cable/server/worker/active_record_connection_management.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module Server
  4. 1 class Worker
  5. 1 module ActiveRecordConnectionManagement
  6. 1 extend ActiveSupport::Concern
  7. 1 included do
  8. 1 if defined?(ActiveRecord::Base)
  9. 1 set_callback :work, :around, :with_database_connections
  10. end
  11. end
  12. 1 def with_database_connections
  13. 1142 connection.logger.tag(ActiveRecord::Base.logger) { yield }
  14. end
  15. end
  16. end
  17. end
  18. end

lib/action_cable/subscription_adapter.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module SubscriptionAdapter
  4. 1 extend ActiveSupport::Autoload
  5. 1 autoload :Base
  6. 1 autoload :Test
  7. 1 autoload :SubscriberMap
  8. 1 autoload :ChannelPrefix
  9. end
  10. end

lib/action_cable/subscription_adapter/async.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "action_cable/subscription_adapter/inline"
  3. 1 module ActionCable
  4. 1 module SubscriptionAdapter
  5. 1 class Async < Inline # :nodoc:
  6. 1 private
  7. 1 def new_subscriber_map
  8. 42 AsyncSubscriberMap.new(server.event_loop)
  9. end
  10. 1 class AsyncSubscriberMap < SubscriberMap
  11. 1 def initialize(event_loop)
  12. 42 @event_loop = event_loop
  13. 42 super()
  14. end
  15. 1 def add_subscriber(*)
  16. 282 @event_loop.post { super }
  17. end
  18. 1 def invoke_callback(*)
  19. 248 @event_loop.post { super }
  20. end
  21. end
  22. end
  23. end
  24. end

lib/action_cable/subscription_adapter/base.rb

94.12% lines covered

17 relevant lines. 16 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module SubscriptionAdapter
  4. 1 class Base
  5. 1 attr_reader :logger, :server
  6. 1 def initialize(server)
  7. 348 @server = server
  8. 348 @logger = @server.logger
  9. end
  10. 1 def broadcast(channel, payload)
  11. 1 raise NotImplementedError
  12. end
  13. 1 def subscribe(channel, message_callback, success_callback = nil)
  14. 1 raise NotImplementedError
  15. end
  16. 1 def unsubscribe(channel, message_callback)
  17. 1 raise NotImplementedError
  18. end
  19. 1 def shutdown
  20. raise NotImplementedError
  21. end
  22. 1 def identifier
  23. 60 @server.config.cable[:id] ||= "ActionCable-PID-#{$$}"
  24. end
  25. end
  26. end
  27. end

lib/action_cable/subscription_adapter/channel_prefix.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module SubscriptionAdapter
  4. 1 module ChannelPrefix # :nodoc:
  5. 1 def broadcast(channel, payload)
  6. 42 channel = channel_with_prefix(channel)
  7. 42 super
  8. end
  9. 1 def subscribe(channel, callback, success_callback = nil)
  10. 39 channel = channel_with_prefix(channel)
  11. 39 super
  12. end
  13. 1 def unsubscribe(channel, callback)
  14. 39 channel = channel_with_prefix(channel)
  15. 39 super
  16. end
  17. 1 private
  18. # Returns the channel name, including channel_prefix specified in cable.yml
  19. 1 def channel_with_prefix(channel)
  20. 120 [@server.config.cable[:channel_prefix], channel].compact.join(":")
  21. end
  22. end
  23. end
  24. end

lib/action_cable/subscription_adapter/inline.rb

100.0% lines covered

18 relevant lines. 18 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module SubscriptionAdapter
  4. 1 class Inline < Base # :nodoc:
  5. 1 def initialize(*)
  6. 262 super
  7. 262 @subscriber_map = nil
  8. end
  9. 1 def broadcast(channel, payload)
  10. 76 subscriber_map.broadcast(channel, payload)
  11. end
  12. 1 def subscribe(channel, callback, success_callback = nil)
  13. 152 subscriber_map.add_subscriber(channel, callback, success_callback)
  14. end
  15. 1 def unsubscribe(channel, callback)
  16. 147 subscriber_map.remove_subscriber(channel, callback)
  17. end
  18. 1 def shutdown
  19. # nothing to do
  20. end
  21. 1 private
  22. 1 def subscriber_map
  23. 425 @subscriber_map || @server.mutex.synchronize { @subscriber_map ||= new_subscriber_map }
  24. end
  25. 1 def new_subscriber_map
  26. 8 SubscriberMap.new
  27. end
  28. end
  29. end
  30. end

lib/action_cable/subscription_adapter/postgresql.rb

0.0% lines covered

107 relevant lines. 0 lines covered and 107 lines missed.
    
  1. # frozen_string_literal: true
  2. gem "pg", "~> 1.1"
  3. require "pg"
  4. require "thread"
  5. require "digest/sha1"
  6. module ActionCable
  7. module SubscriptionAdapter
  8. class PostgreSQL < Base # :nodoc:
  9. prepend ChannelPrefix
  10. def initialize(*)
  11. super
  12. @listener = nil
  13. end
  14. def broadcast(channel, payload)
  15. with_broadcast_connection do |pg_conn|
  16. pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel_identifier(channel))}, '#{pg_conn.escape_string(payload)}'")
  17. end
  18. end
  19. def subscribe(channel, callback, success_callback = nil)
  20. listener.add_subscriber(channel_identifier(channel), callback, success_callback)
  21. end
  22. def unsubscribe(channel, callback)
  23. listener.remove_subscriber(channel_identifier(channel), callback)
  24. end
  25. def shutdown
  26. listener.shutdown
  27. end
  28. def with_subscriptions_connection(&block) # :nodoc:
  29. ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
  30. # Action Cable is taking ownership over this database connection, and
  31. # will perform the necessary cleanup tasks
  32. ActiveRecord::Base.connection_pool.remove(conn)
  33. end
  34. pg_conn = ar_conn.raw_connection
  35. verify!(pg_conn)
  36. pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(identifier)}")
  37. yield pg_conn
  38. ensure
  39. ar_conn.disconnect!
  40. end
  41. def with_broadcast_connection(&block) # :nodoc:
  42. ActiveRecord::Base.connection_pool.with_connection do |ar_conn|
  43. pg_conn = ar_conn.raw_connection
  44. verify!(pg_conn)
  45. yield pg_conn
  46. end
  47. end
  48. private
  49. def channel_identifier(channel)
  50. channel.size > 63 ? Digest::SHA1.hexdigest(channel) : channel
  51. end
  52. def listener
  53. @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) }
  54. end
  55. def verify!(pg_conn)
  56. unless pg_conn.is_a?(PG::Connection)
  57. raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter"
  58. end
  59. end
  60. class Listener < SubscriberMap
  61. def initialize(adapter, event_loop)
  62. super()
  63. @adapter = adapter
  64. @event_loop = event_loop
  65. @queue = Queue.new
  66. @thread = Thread.new do
  67. Thread.current.abort_on_exception = true
  68. listen
  69. end
  70. end
  71. def listen
  72. @adapter.with_subscriptions_connection do |pg_conn|
  73. catch :shutdown do
  74. loop do
  75. until @queue.empty?
  76. action, channel, callback = @queue.pop(true)
  77. case action
  78. when :listen
  79. pg_conn.exec("LISTEN #{pg_conn.escape_identifier channel}")
  80. @event_loop.post(&callback) if callback
  81. when :unlisten
  82. pg_conn.exec("UNLISTEN #{pg_conn.escape_identifier channel}")
  83. when :shutdown
  84. throw :shutdown
  85. end
  86. end
  87. pg_conn.wait_for_notify(1) do |chan, pid, message|
  88. broadcast(chan, message)
  89. end
  90. end
  91. end
  92. end
  93. end
  94. def shutdown
  95. @queue.push([:shutdown])
  96. Thread.pass while @thread.alive?
  97. end
  98. def add_channel(channel, on_success)
  99. @queue.push([:listen, channel, on_success])
  100. end
  101. def remove_channel(channel)
  102. @queue.push([:unlisten, channel])
  103. end
  104. def invoke_callback(*)
  105. @event_loop.post { super }
  106. end
  107. end
  108. end
  109. end
  110. end

lib/action_cable/subscription_adapter/redis.rb

100.0% lines covered

94 relevant lines. 94 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "thread"
  3. 1 gem "redis", ">= 3", "< 5"
  4. 1 require "redis"
  5. 1 require "active_support/core_ext/hash/except"
  6. 1 module ActionCable
  7. 1 module SubscriptionAdapter
  8. 1 class Redis < Base # :nodoc:
  9. 1 prepend ChannelPrefix
  10. # Overwrite this factory method for Redis connections if you want to use a different Redis library than the redis gem.
  11. # This is needed, for example, when using Makara proxies for distributed Redis.
  12. 1 cattr_accessor :redis_connector, default: ->(config) do
  13. 60 ::Redis.new(config.except(:adapter, :channel_prefix))
  14. end
  15. 1 def initialize(*)
  16. 63 super
  17. 63 @listener = nil
  18. 63 @redis_connection_for_broadcasts = nil
  19. end
  20. 1 def broadcast(channel, payload)
  21. 42 redis_connection_for_broadcasts.publish(channel, payload)
  22. end
  23. 1 def subscribe(channel, callback, success_callback = nil)
  24. 39 listener.add_subscriber(channel, callback, success_callback)
  25. end
  26. 1 def unsubscribe(channel, callback)
  27. 39 listener.remove_subscriber(channel, callback)
  28. end
  29. 1 def shutdown
  30. 54 @listener.shutdown if @listener
  31. end
  32. 1 def redis_connection_for_subscriptions
  33. 30 redis_connection
  34. end
  35. 1 private
  36. 1 def listener
  37. 108 @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) }
  38. end
  39. 1 def redis_connection_for_broadcasts
  40. 42 @redis_connection_for_broadcasts || @server.mutex.synchronize do
  41. 27 @redis_connection_for_broadcasts ||= redis_connection
  42. end
  43. end
  44. 1 def redis_connection
  45. 60 self.class.redis_connector.call(@server.config.cable.merge(id: identifier))
  46. end
  47. 1 class Listener < SubscriberMap
  48. 1 def initialize(adapter, event_loop)
  49. 30 super()
  50. 30 @adapter = adapter
  51. 30 @event_loop = event_loop
  52. 96 @subscribe_callbacks = Hash.new { |h, k| h[k] = [] }
  53. 30 @subscription_lock = Mutex.new
  54. 30 @raw_client = nil
  55. 30 @when_connected = []
  56. 30 @thread = nil
  57. end
  58. 1 def listen(conn)
  59. 30 conn.without_reconnect do
  60. 30 original_client = conn.respond_to?(:_client) ? conn._client : conn.client
  61. 30 conn.subscribe("_action_cable_internal") do |on|
  62. 30 on.subscribe do |chan, count|
  63. 66 @subscription_lock.synchronize do
  64. 66 if count == 1
  65. 30 @raw_client = original_client
  66. 30 until @when_connected.empty?
  67. 30 @when_connected.shift.call
  68. end
  69. end
  70. 66 if callbacks = @subscribe_callbacks[chan]
  71. 66 next_callback = callbacks.shift
  72. 66 @event_loop.post(&next_callback) if next_callback
  73. 66 @subscribe_callbacks.delete(chan) if callbacks.empty?
  74. end
  75. end
  76. end
  77. 30 on.message do |chan, message|
  78. 36 broadcast(chan, message)
  79. end
  80. 30 on.unsubscribe do |chan, count|
  81. 63 if count == 0
  82. 27 @subscription_lock.synchronize do
  83. 27 @raw_client = nil
  84. end
  85. end
  86. end
  87. end
  88. end
  89. end
  90. 1 def shutdown
  91. 27 @subscription_lock.synchronize do
  92. 27 return if @thread.nil?
  93. 27 when_connected do
  94. 27 send_command("unsubscribe")
  95. 27 @raw_client = nil
  96. end
  97. end
  98. 27 Thread.pass while @thread.alive?
  99. end
  100. 1 def add_channel(channel, on_success)
  101. 36 @subscription_lock.synchronize do
  102. 36 ensure_listener_running
  103. 36 @subscribe_callbacks[channel] << on_success
  104. 72 when_connected { send_command("subscribe", channel) }
  105. end
  106. end
  107. 1 def remove_channel(channel)
  108. 36 @subscription_lock.synchronize do
  109. 72 when_connected { send_command("unsubscribe", channel) }
  110. end
  111. end
  112. 1 def invoke_callback(*)
  113. 78 @event_loop.post { super }
  114. end
  115. 1 private
  116. 1 def ensure_listener_running
  117. 36 @thread ||= Thread.new do
  118. 30 Thread.current.abort_on_exception = true
  119. 30 conn = @adapter.redis_connection_for_subscriptions
  120. 30 listen conn
  121. end
  122. end
  123. 1 def when_connected(&block)
  124. 99 if @raw_client
  125. 69 block.call
  126. else
  127. 30 @when_connected << block
  128. end
  129. end
  130. 1 def send_command(*command)
  131. 99 @raw_client.write(command)
  132. 99 very_raw_connection =
  133. @raw_client.connection.instance_variable_defined?(:@connection) &&
  134. @raw_client.connection.instance_variable_get(:@connection)
  135. 99 if very_raw_connection && very_raw_connection.respond_to?(:flush)
  136. 33 very_raw_connection.flush
  137. end
  138. end
  139. end
  140. end
  141. end
  142. end

lib/action_cable/subscription_adapter/subscriber_map.rb

100.0% lines covered

31 relevant lines. 31 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. 1 module SubscriptionAdapter
  4. 1 class SubscriberMap
  5. 1 def initialize
  6. 206 @subscribers = Hash.new { |h, k| h[k] = [] }
  7. 81 @sync = Mutex.new
  8. end
  9. 1 def add_subscriber(channel, subscriber, on_success)
  10. 191 @sync.synchronize do
  11. 191 new_channel = !@subscribers.key?(channel)
  12. 191 @subscribers[channel] << subscriber
  13. 191 if new_channel
  14. 77 add_channel channel, on_success
  15. 114 elsif on_success
  16. 114 on_success.call
  17. end
  18. end
  19. end
  20. 1 def remove_subscriber(channel, subscriber)
  21. 186 @sync.synchronize do
  22. 186 @subscribers[channel].delete(subscriber)
  23. 186 if @subscribers[channel].empty?
  24. 119 @subscribers.delete channel
  25. 119 remove_channel channel
  26. end
  27. end
  28. end
  29. 1 def broadcast(channel, message)
  30. 113 list = @sync.synchronize do
  31. 113 return if !@subscribers.key?(channel)
  32. 78 @subscribers[channel].dup
  33. end
  34. 78 list.each do |subscriber|
  35. 174 invoke_callback(subscriber, message)
  36. end
  37. end
  38. 1 def add_channel(channel, on_success)
  39. 41 on_success.call if on_success
  40. end
  41. 1 def remove_channel(channel)
  42. end
  43. 1 def invoke_callback(callback, message)
  44. 174 callback.call message
  45. end
  46. end
  47. end
  48. end

lib/action_cable/subscription_adapter/test.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "async"
  3. 1 module ActionCable
  4. 1 module SubscriptionAdapter
  5. # == Test adapter for Action Cable
  6. #
  7. # The test adapter should be used only in testing. Along with
  8. # <tt>ActionCable::TestHelper</tt> it makes a great tool to test your Rails application.
  9. #
  10. # To use the test adapter set +adapter+ value to +test+ in your +config/cable.yml+ file.
  11. #
  12. # NOTE: Test adapter extends the <tt>ActionCable::SubscriptionsAdapter::Async</tt> adapter,
  13. # so it could be used in system tests too.
  14. 1 class Test < Async
  15. 1 def broadcast(channel, payload)
  16. 40 broadcasts(channel) << payload
  17. 40 super
  18. end
  19. 1 def broadcasts(channel)
  20. 72 channels_data[channel] ||= []
  21. end
  22. 1 def clear_messages(channel)
  23. 9 channels_data[channel] = []
  24. end
  25. 1 def clear
  26. 1 @channels_data = nil
  27. end
  28. 1 private
  29. 1 def channels_data
  30. 81 @channels_data ||= {}
  31. end
  32. end
  33. end
  34. end

lib/action_cable/test_case.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/test_case"
  3. 1 module ActionCable
  4. 1 class TestCase < ActiveSupport::TestCase
  5. 1 include ActionCable::TestHelper
  6. 1 ActiveSupport.run_load_hooks(:action_cable_test_case, self)
  7. end
  8. end

lib/action_cable/test_helper.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionCable
  3. # Provides helper methods for testing Action Cable broadcasting
  4. 1 module TestHelper
  5. 1 def before_setup # :nodoc:
  6. 197 server = ActionCable.server
  7. 197 test_adapter = ActionCable::SubscriptionAdapter::Test.new(server)
  8. 197 @old_pubsub_adapter = server.pubsub
  9. 197 server.instance_variable_set(:@pubsub, test_adapter)
  10. 197 super
  11. end
  12. 1 def after_teardown # :nodoc:
  13. 197 super
  14. 197 ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter)
  15. end
  16. # Asserts that the number of broadcasted messages to the stream matches the given number.
  17. #
  18. # def test_broadcasts
  19. # assert_broadcasts 'messages', 0
  20. # ActionCable.server.broadcast 'messages', { text: 'hello' }
  21. # assert_broadcasts 'messages', 1
  22. # ActionCable.server.broadcast 'messages', { text: 'world' }
  23. # assert_broadcasts 'messages', 2
  24. # end
  25. #
  26. # If a block is passed, that block should cause the specified number of
  27. # messages to be broadcasted.
  28. #
  29. # def test_broadcasts_again
  30. # assert_broadcasts('messages', 1) do
  31. # ActionCable.server.broadcast 'messages', { text: 'hello' }
  32. # end
  33. #
  34. # assert_broadcasts('messages', 2) do
  35. # ActionCable.server.broadcast 'messages', { text: 'hi' }
  36. # ActionCable.server.broadcast 'messages', { text: 'how are you?' }
  37. # end
  38. # end
  39. #
  40. 1 def assert_broadcasts(stream, number, &block)
  41. 9 if block_given?
  42. 6 original_count = broadcasts_size(stream)
  43. 6 assert_nothing_raised(&block)
  44. 6 new_count = broadcasts_size(stream)
  45. 6 actual_count = new_count - original_count
  46. else
  47. 3 actual_count = broadcasts_size(stream)
  48. end
  49. 9 assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
  50. end
  51. # Asserts that no messages have been sent to the stream.
  52. #
  53. # def test_no_broadcasts
  54. # assert_no_broadcasts 'messages'
  55. # ActionCable.server.broadcast 'messages', { text: 'hi' }
  56. # assert_broadcasts 'messages', 1
  57. # end
  58. #
  59. # If a block is passed, that block should not cause any message to be sent.
  60. #
  61. # def test_broadcasts_again
  62. # assert_no_broadcasts 'messages' do
  63. # # No job messages should be sent from this block
  64. # end
  65. # end
  66. #
  67. # Note: This assertion is simply a shortcut for:
  68. #
  69. # assert_broadcasts 'messages', 0, &block
  70. #
  71. 1 def assert_no_broadcasts(stream, &block)
  72. 3 assert_broadcasts stream, 0, &block
  73. end
  74. # Asserts that the specified message has been sent to the stream.
  75. #
  76. # def test_assert_transmitted_message
  77. # ActionCable.server.broadcast 'messages', text: 'hello'
  78. # assert_broadcast_on('messages', text: 'hello')
  79. # end
  80. #
  81. # If a block is passed, that block should cause a message with the specified data to be sent.
  82. #
  83. # def test_assert_broadcast_on_again
  84. # assert_broadcast_on('messages', text: 'hello') do
  85. # ActionCable.server.broadcast 'messages', text: 'hello'
  86. # end
  87. # end
  88. #
  89. 1 def assert_broadcast_on(stream, data, &block)
  90. # Encode to JSON and back–we want to use this value to compare
  91. # with decoded JSON.
  92. # Comparing JSON strings doesn't work due to the order if the keys.
  93. 7 serialized_msg =
  94. ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data))
  95. 7 new_messages = broadcasts(stream)
  96. 7 if block_given?
  97. 4 old_messages = new_messages
  98. 4 clear_messages(stream)
  99. 4 assert_nothing_raised(&block)
  100. 4 new_messages = broadcasts(stream)
  101. 4 clear_messages(stream)
  102. # Restore all sent messages
  103. 8 (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) }
  104. end
  105. 15 message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg }
  106. 7 assert message, "No messages sent with #{data} to #{stream}"
  107. end
  108. 1 def pubsub_adapter # :nodoc:
  109. 38 ActionCable.server.pubsub
  110. end
  111. 1 delegate :broadcasts, :clear_messages, to: :pubsub_adapter
  112. 1 private
  113. 1 def broadcasts_size(channel)
  114. 15 broadcasts(channel).size
  115. end
  116. end
  117. end

lib/action_cable/version.rb

75.0% lines covered

4 relevant lines. 3 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "gem_version"
  3. 1 module ActionCable
  4. # Returns the version of the currently loaded Action Cable as a <tt>Gem::Version</tt>
  5. 1 def self.version
  6. gem_version
  7. end
  8. end

lib/rails/generators/channel/channel_generator.rb

0.0% lines covered

37 relevant lines. 0 lines covered and 37 lines missed.
    
  1. # frozen_string_literal: true
  2. module Rails
  3. module Generators
  4. class ChannelGenerator < NamedBase
  5. source_root File.expand_path("templates", __dir__)
  6. argument :actions, type: :array, default: [], banner: "method method"
  7. class_option :assets, type: :boolean
  8. check_class_collision suffix: "Channel"
  9. hook_for :test_framework
  10. def create_channel_file
  11. template "channel.rb", File.join("app/channels", class_path, "#{file_name}_channel.rb")
  12. if options[:assets]
  13. if behavior == :invoke
  14. template "javascript/index.js", "app/javascript/channels/index.js"
  15. template "javascript/consumer.js", "app/javascript/channels/consumer.js"
  16. end
  17. js_template "javascript/channel", File.join("app/javascript/channels", class_path, "#{file_name}_channel")
  18. end
  19. generate_application_cable_files
  20. end
  21. private
  22. def file_name
  23. @_file_name ||= super.sub(/_channel\z/i, "")
  24. end
  25. # FIXME: Change these files to symlinks once RubyGems 2.5.0 is required.
  26. def generate_application_cable_files
  27. return if behavior != :invoke
  28. files = [
  29. "application_cable/channel.rb",
  30. "application_cable/connection.rb"
  31. ]
  32. files.each do |name|
  33. path = File.join("app/channels/", name)
  34. template(name, path) if !File.exist?(path)
  35. end
  36. end
  37. end
  38. end
  39. end

lib/rails/generators/test_unit/channel_generator.rb

0.0% lines covered

15 relevant lines. 0 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. module TestUnit
  3. module Generators
  4. class ChannelGenerator < ::Rails::Generators::NamedBase
  5. source_root File.expand_path("templates", __dir__)
  6. check_class_collision suffix: "ChannelTest"
  7. def create_test_files
  8. template "channel_test.rb", File.join("test/channels", class_path, "#{file_name}_channel_test.rb")
  9. end
  10. private
  11. def file_name # :doc:
  12. @_file_name ||= super.sub(/_channel\z/i, "")
  13. end
  14. end
  15. end
  16. end