cable_room 0.6.2 → 0.7.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +1190 -0
  4. data/cable_room.gemspec +5 -2
  5. data/exe/cable_room +8 -0
  6. data/lib/cable_room/broadcaster.rb +116 -0
  7. data/lib/cable_room/bus.rb +372 -0
  8. data/lib/cable_room/cli.rb +237 -0
  9. data/lib/cable_room/config.rb +112 -0
  10. data/lib/cable_room/host/bus_inbound.rb +36 -0
  11. data/lib/cable_room/host/runner.rb +571 -0
  12. data/lib/cable_room/host/supervisor.rb +275 -0
  13. data/lib/cable_room/host/worker_pool.rb +37 -0
  14. data/lib/cable_room/host.rb +477 -0
  15. data/lib/cable_room/membership_store.rb +105 -0
  16. data/lib/cable_room/migration.rb +586 -0
  17. data/lib/cable_room/periodic_timer.rb +18 -0
  18. data/lib/cable_room/placement.rb +258 -0
  19. data/lib/cable_room/ports.rb +19 -11
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +57 -0
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +78 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_member.rb +260 -84
  31. data/lib/cable_room/room_proxy_channel.rb +13 -2
  32. data/lib/cable_room/snapshot.rb +136 -0
  33. data/lib/cable_room/version.rb +1 -1
  34. data/lib/cable_room.rb +57 -2
  35. metadata +25 -9
  36. data/lib/cable_room/channel_base.rb +0 -262
  37. data/lib/cable_room/channel_tracker.rb +0 -130
  38. data/lib/cable_room/room/channel_adapter.rb +0 -18
@@ -59,7 +59,7 @@ module CableRoom
59
59
  protected
60
60
 
61
61
  def ping_watchdog
62
- @cable_channel.ping_watchdog
62
+ @runner.ping_watchdog
63
63
  end
64
64
 
65
65
  def check_reapers_now!
@@ -74,6 +74,39 @@ module CableRoom
74
74
  ping_watchdog
75
75
  end
76
76
 
77
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
78
+
79
+ # One entry per `reap_when`, in declaration order. `last_keep_at` is the wall-clock moment
80
+ # the reaper's grace period started counting from (nil until it has run once), so the
81
+ # deadline — `last_keep_at + grace` — is the same on whichever host restores it.
82
+ def _snapshot_reaper_state
83
+ self.class.reaper_checkers.each_with_index.map do |cfg, index|
84
+ state = _reaper_states[cfg] || {}
85
+ { key: cfg[:key]&.to_s, index: index, last_keep_at: Snapshot.encode_time(state[:last_keep_at]) }
86
+ end
87
+ end
88
+
89
+ # Runs before the startup callbacks, which add the timer to each state and leave the rest
90
+ # alone. A named reaper matches by name and an anonymous one by position, so a Room whose
91
+ # reapers changed between the two hosts' code versions keeps what still lines up and drops
92
+ # the rest.
93
+ def _restore_reaper_state(entries)
94
+ checkers = self.class.reaper_checkers
95
+ Array(entries).each do |entry|
96
+ cfg =
97
+ if entry["key"].present?
98
+ checkers.find { |c| c[:key].to_s == entry["key"] }
99
+ else
100
+ candidate = checkers[entry["index"].to_i]
101
+ candidate if candidate && candidate[:key].nil?
102
+ end
103
+ next unless cfg
104
+
105
+ last_keep_at = Snapshot.decode_time(entry["last_keep_at"])
106
+ (_reaper_states[cfg] ||= {})[:last_keep_at] = last_keep_at if last_keep_at
107
+ end
108
+ end
109
+
77
110
  private
78
111
 
79
112
  def _reaper_states
@@ -0,0 +1,78 @@
1
+ module CableRoom
2
+ module Room
3
+ # The room's side of a migration: turning itself into a CableRoom::Snapshot and coming back
4
+ # from one. The gem carries what it owns (ports, tags, users, reaper deadlines); a Room can
5
+ # carry its own state too with two optional hooks:
6
+ #
7
+ # class QuizRoom < CableRoom::Room::Base
8
+ # def snapshot_state
9
+ # { question_id: @question.id, answers: @answers } # JSON only; records by id
10
+ # end
11
+ #
12
+ # def restore_state(state)
13
+ # @question = Question.find(state[:question_id]) # indifferent access: :key or "key"
14
+ # @answers = state[:answers]
15
+ # end
16
+ # end
17
+ #
18
+ # A room that defines `restore_state` gets it instead of `startup` when it's restored, with
19
+ # whatever `snapshot_state` returned (nil if the room that was snapshotted had no
20
+ # `snapshot_state`). A room without `restore_state` just runs `startup` again and rebuilds its
21
+ # state the way it did the first time. Either way the `before_startup`/`after_startup`
22
+ # callbacks run as usual, except that the gem does not broadcast `room_opened`: the members
23
+ # were there the whole time and never saw the room go away. `restored?` tells a callback which
24
+ # case it's in.
25
+ module Snapshotting
26
+ extend ActiveSupport::Concern
27
+
28
+ # True for a room that was rebuilt from a snapshot rather than started fresh.
29
+ def restored?
30
+ @_restored == true
31
+ end
32
+
33
+ private
34
+
35
+ # Build the snapshot (see CableRoom::Snapshot for the shape). Only meaningful once the room
36
+ # is frozen: nothing else may be touching its state while this reads it.
37
+ def _snapshot
38
+ document = {
39
+ version: Snapshot::VERSION,
40
+ room_class: self.class.name,
41
+ key: Snapshot.serialize_argument(key),
42
+ port_clients: _snapshot_port_clients,
43
+ user_state: _snapshot_user_state,
44
+ reaper_state: _snapshot_reaper_state,
45
+ app_state: _snapshot_app_state,
46
+ }
47
+
48
+ ActiveSupport::Notifications.instrument("room_snapshotted.cable_room", { room: self }) do
49
+ Snapshot.round_trip(document)
50
+ end
51
+ end
52
+
53
+ def _snapshot_app_state
54
+ return nil unless respond_to?(:snapshot_state, true)
55
+
56
+ state = snapshot_state
57
+ Snapshot.assert_json!(state, room: self)
58
+ state
59
+ end
60
+
61
+ # Rebuild from `snapshot`: gem state first, quietly (no port_connected or user_joined
62
+ # callbacks, since none of it is new), then the startup chain with `restore_state` in place
63
+ # of `startup`. Runs where `_startup` would, before the runner starts the periodic timers.
64
+ def _restore(snapshot)
65
+ snapshot = Snapshot.validate!(snapshot)
66
+ @_restored = true
67
+
68
+ _restore_port_clients(snapshot["port_clients"])
69
+ _restore_user_state(snapshot["user_state"])
70
+ _restore_reaper_state(snapshot["reaper_state"])
71
+
72
+ app_state = snapshot["app_state"]
73
+ app_state = app_state.with_indifferent_access if app_state.is_a?(Hash)
74
+ _startup(restoring: true, app_state: app_state)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -36,13 +36,13 @@ module CableRoom
36
36
  # results back with `on_room_thread` over waiting for them.
37
37
  def async(&blk)
38
38
  room = self
39
- @cable_channel.post_work(async: true) { room.instance_exec(&blk) }
39
+ @runner.post_work(async: true) { room.instance_exec(&blk) }
40
40
  end
41
41
 
42
42
  # Queue the block back onto the Room's own thread, where touching Room state is safe again.
43
43
  def on_room_thread(&blk)
44
44
  room = self
45
- @cable_channel.post_work(async: false, silent: true) { room.instance_exec(&blk) }
45
+ @runner.post_work(async: false, silent: true) { room.instance_exec(&blk) }
46
46
  end
47
47
  end
48
48
  end
@@ -98,6 +98,33 @@ module CableRoom
98
98
  tags
99
99
  end
100
100
 
101
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
102
+
103
+ # The user map is carried on its own rather than rebuilt from the ports: a port whose join
104
+ # the tag policy refused has no user entry, and a rebuild would invent one.
105
+ def _snapshot_user_state
106
+ @_user_map_mutex.synchronize do
107
+ @_user_state_map.map do |user, usm|
108
+ { user: Snapshot.serialize_argument(user), port_tokens: usm[:port_tokens].to_a }
109
+ end
110
+ end
111
+ end
112
+
113
+ # No user_joined callbacks here: these users joined long ago, on the previous host.
114
+ def _restore_user_state(entries)
115
+ @_user_map_mutex.synchronize do
116
+ Array(entries).each do |entry|
117
+ begin
118
+ user = Snapshot.deserialize_argument(entry["user"])
119
+ rescue ::ActiveJob::DeserializationError => e
120
+ logger.warn "Dropping a user from the snapshot: #{e.message}"
121
+ next
122
+ end
123
+ @_user_state_map[user] = { port_tokens: Set.new(entry["port_tokens"]) }
124
+ end
125
+ end
126
+ end
127
+
101
128
  def _apply_port_scope(user: nil, tag: nil, **kwargs)
102
129
  if user && tag
103
130
  user_tags = all_user_tags(user) || []
@@ -7,9 +7,10 @@ module CableRoom
7
7
 
8
8
  autoload :Callbacks
9
9
  autoload :Threading
10
- autoload :ChannelAdapter
10
+ autoload :HostAdapter
11
11
 
12
12
  autoload :Lifecycle
13
+ autoload :Snapshotting
13
14
  autoload :Reaping
14
15
  autoload :InputHandling
15
16
 
@@ -20,8 +21,10 @@ module CableRoom
20
21
  autoload :Broadcasting
21
22
  end
22
23
 
24
+ # Every room running in this process. Asking never creates a Host, so a process that hosts
25
+ # no rooms (a web process in :remote) just gets an empty list.
23
26
  def self.locally_open_rooms
24
- ChannelTracker.instance.room_channels.map(&:room)
27
+ Host.current&.rooms || []
25
28
  end
26
29
  end
27
30
  end
@@ -1,4 +1,8 @@
1
1
  module CableRoom
2
+ # Raised at subscribe time when `join_room` asks for something AnyCable can't deliver
3
+ # (see RoomMember#refuse_unsupported_under_anycable!).
4
+ class AnyCableUnsupported < ArgumentError; end
5
+
2
6
  module RoomMember
3
7
  extend ActiveSupport::Concern
4
8
 
@@ -9,17 +13,19 @@ module CableRoom
9
13
  # ~30% of all room messages at 10 s with no skipping.
10
14
  PING_INTERVAL = 15.seconds
11
15
 
12
- # A dropped announcement is invisible to the port. The room's inbound subscription is
13
- # registered asynchronously — and the room may live in another process entirely — so a
14
- # port_connected sent before the room is listening is discarded with no error. Re-announce on
15
- # a doubling backoff from here until the room acknowledges, rather than waiting out a whole
16
- # PING_INTERVAL. port_connected is idempotent on the room side (the port is merged,
17
- # user_joined fires only once), so a redundant re-announcement costs one message.
18
- REANNOUNCE_INITIAL_DELAY = 0.1.seconds
19
-
20
16
  included do
21
17
  periodically :ping_room_memberships, every: PING_INTERVAL
22
18
 
19
+ # Where MembershipStore::AnyCable keeps the memberships between anycable-rails' per-call
20
+ # channel instances. `state_attr_accessor` only exists once anycable-rails is loaded; without
21
+ # it the channel lives for the socket and the in-memory store is all there is. Private
22
+ # because every public method of a channel is an action a client can perform, and a client
23
+ # that could write this could claim another member's token.
24
+ if respond_to?(:state_attr_accessor)
25
+ state_attr_accessor MembershipStore::AnyCable::STATE_ATTRIBUTE
26
+ private MembershipStore::AnyCable::STATE_ATTRIBUTE, :"#{MembershipStore::AnyCable::STATE_ATTRIBUTE}="
27
+ end
28
+
23
29
  after_unsubscribe do
24
30
  to_close = _room_memberships.to_a
25
31
  _room_memberships.clear
@@ -27,13 +33,51 @@ module CableRoom
27
33
  end
28
34
  end
29
35
 
36
+ # The channel's memberships (a MembershipStore). RoomMembership registers itself here, so this
37
+ # has to stay public; treat it as internal and read `room_memberships` instead.
30
38
  def _room_memberships
31
- @_room_memberships ||= Set.new
39
+ @_room_memberships ||= MembershipStore.for(self)
40
+ end
41
+
42
+ # True when anycable-rails is handling this channel: the connection carries an AnyCable socket,
43
+ # this instance exists for one RPC call only, its streams are held by anycable-go, and its
44
+ # timers never run. `anycabled?` is what anycable-rails itself checks; it only exists once the
45
+ # gem is loaded. (Public methods are actions; performing this one is harmless.)
46
+ def anycable_channel?
47
+ connection.respond_to?(:anycabled?) && !!connection.anycabled?
48
+ end
49
+
50
+ # The `hello` channel action. The browser performs it once ActionCable has confirmed the
51
+ # subscription (`connected()` in the JS client), which is the first moment every stream this
52
+ # channel opened is guaranteed live. Each membership announces itself to its room in response;
53
+ # nothing is announced before then. A channel with no memberships ignores it.
54
+ #
55
+ # Public methods on a channel are its actions, so every RoomMember channel accepts
56
+ # `perform("hello")` without further wiring.
57
+ def hello(_data = nil)
58
+ hello_room_memberships
59
+ end
60
+
61
+ # The `ping` channel action. Under ActionCable the channel's own timer pings, so a client that
62
+ # performs this is doing harmless extra work. Under AnyCable channel timers never run (there is
63
+ # no long-lived channel object to run them on), so the browser performs this every
64
+ # PING_INTERVAL and each membership turns it into port_ping. See README "Using AnyCable".
65
+ def ping(_data = nil)
66
+ ping_room_memberships
32
67
  end
33
68
 
34
69
  protected
35
70
 
71
+ # The memberships `join_room` created on this channel. Under AnyCable the instance variable
72
+ # `subscribed` assigned is gone by the next call; this still has them, rebuilt from the
73
+ # channel state.
74
+ def room_memberships
75
+ _room_memberships
76
+ end
77
+
36
78
  def join_room(room_class, room_key = nil, as: :not_given, forward: false, **kwargs, &blk)
79
+ refuse_unsupported_under_anycable!(forward: forward, callbacks: kwargs, block: blk)
80
+
37
81
  if forward
38
82
  # raise ArgumentError, "Cannot specify both `forward: true` and `on_message:`" if kwargs[:on_message]
39
83
  original_on_message = kwargs[:on_message]
@@ -57,13 +101,33 @@ module CableRoom
57
101
  _room_memberships.each(&:ping!)
58
102
  end
59
103
 
60
- def transmit_subscription_confirmation(...)
61
- super
62
- memberships = _room_memberships.to_a
63
- memberships.each(&:streams_live!)
64
- connection.worker_pool.async_exec(self, connection: connection) do
65
- memberships.each(&:transmit_port_connected)
66
- end
104
+ # Under AnyCable, room→member broadcasts go anycable-go → socket and never pass through this
105
+ # process, and the channel object is rebuilt per call, so nothing here can run a proc when a
106
+ # message arrives. That rules out `forward: false` (nothing would deliver messages to the app),
107
+ # every `on_*:` callback, and a preconfigure block (custom stream handlers). Failing at
108
+ # subscribe time beats a join that silently never fires anything. RoomProxyChannel's own
109
+ # `forward` wrapper is added after this check, so it is exempt. Plain ActionCable: no-op.
110
+ UNSUPPORTED_UNDER_ANYCABLE_CALLBACKS = %i[on_joined on_message on_room_opened on_room_closed on_left].freeze
111
+
112
+ def refuse_unsupported_under_anycable!(forward:, callbacks:, block:)
113
+ return unless anycable_channel?
114
+
115
+ unsupported = []
116
+ unsupported << "forward: false" unless forward
117
+ unsupported.concat(UNSUPPORTED_UNDER_ANYCABLE_CALLBACKS.select { |cb| callbacks[cb] }.map { |cb| "#{cb}:" })
118
+ unsupported << "a preconfigure block" if block
119
+ return if unsupported.empty?
120
+
121
+ raise AnyCableUnsupported,
122
+ "join_room used #{unsupported.join(', ')} on an AnyCable-backed channel (#{self.class.name}). " \
123
+ "Under AnyCable room messages never pass through this process, so these can't work; " \
124
+ "use forward: true without callbacks (see README, \"Using AnyCable\")."
125
+ end
126
+
127
+ # Server-side entry point for the hello signal, for channels that learn the client is ready
128
+ # some other way than the `hello` action (a custom handshake message, for instance).
129
+ def hello_room_memberships
130
+ _room_memberships.each(&:hello!)
67
131
  end
68
132
  end
69
133
 
@@ -90,9 +154,7 @@ module CableRoom
90
154
 
91
155
  @has_left = false
92
156
  @has_established = false
93
- @streams_live = false
94
- @reannounce_delay = nil
95
- @reannounce_job = nil
157
+ @hello_received = false
96
158
 
97
159
  @cable_channel = cable_channel
98
160
  @room_class = room_class
@@ -126,11 +188,94 @@ module CableRoom
126
188
  @mutex.synchronize { @has_left }
127
189
  end
128
190
 
191
+ def hello_received?
192
+ @hello_received
193
+ end
194
+
195
+ # Whether room→member traffic reaches this object. Under ActionCable the streams opened in
196
+ # initiate_connection dispatch into handle_received_message. Under AnyCable anycable-go holds
197
+ # the streams and delivers them straight to the socket, so this process never sees a room
198
+ # message: acknowledgements, room_opened, and room_closed are invisible here, and none of the
199
+ # on_* callbacks can fire.
200
+ def hears_room?
201
+ !@cable_channel.anycable_channel?
202
+ end
203
+
204
+ # Whether the room has acknowledged this port, as far as this process can tell. Under
205
+ # ActionCable that is the acknowledgement itself. Under AnyCable the acknowledgement went to
206
+ # the socket, so the most this process knows is that hello went out; the browser, which does
207
+ # see port_acknowledged, owns the retry (README "Using AnyCable").
208
+ def presumed_established?
209
+ hears_room? ? @has_established : @hello_received
210
+ end
211
+
212
+ # Whether client input can be handed to the room. Under ActionCable input waits for the
213
+ # acknowledgement, because until then the room has no port to attribute it to. Under AnyCable
214
+ # it goes as soon as hello has: hello's port_connected and the input travel the same Bus
215
+ # channel from the same process, so the room sees them in that order, and a room that doesn't
216
+ # exist yet drops both the way ActionCable would have dropped the input.
217
+ def accepts_input?
218
+ !left? && presumed_established?
219
+ end
220
+
221
+ # What MembershipStore::AnyCable writes into the channel state: enough to rebuild this
222
+ # membership on a later RPC call (see .restore). Callbacks are procs and can't go; `extra`
223
+ # travels in the ActiveJob-serialized form the room deserializes, which needs no lookup here.
224
+ def persisted_identity
225
+ {
226
+ "token" => @token,
227
+ "room_class" => room_class.name,
228
+ "room_key" => @room_key,
229
+ "tags" => @tags,
230
+ "extra" => serialized_extra,
231
+ "create" => @allow_create,
232
+ "hello_received" => @hello_received,
233
+ }
234
+ end
235
+
236
+ # The inverse of #persisted_identity, for a channel instance anycable-rails built for one RPC
237
+ # call. The result can hello!, ping!, leave!, and forward input with the token the subscribe
238
+ # call created. It does not open streams (anycable-go still holds the ones subscribe opened)
239
+ # and does not announce itself (only hello! does); it carries no callbacks.
240
+ def self.restore(cable_channel, record)
241
+ membership = allocate
242
+ membership.send(:restore_from, cable_channel, record)
243
+ membership
244
+ end
245
+
246
+ # The client has said hello: its subscription is confirmed, so every stream this membership
247
+ # opened is live and the room's acknowledgement has somewhere to land. This is the only thing
248
+ # that lets port_connected go out — a membership that never hears hello never announces.
249
+ #
250
+ # Hello is remembered for the life of the membership, across rejoin!: the browser says it
251
+ # once per subscription, and a rejoin happens server-side without the browser knowing.
252
+ def hello!
253
+ return if left?
254
+
255
+ ActiveSupport::Notifications.instrument(
256
+ "hello_received.cable_room",
257
+ { membership: self, room_class: room_class, room_key: @room_key, channel: @cable_channel }
258
+ ) do
259
+ @hello_received = true
260
+ # Under AnyCable the next call starts from a fresh channel; it has to know hello happened.
261
+ @cable_channel._room_memberships.persist!
262
+ # A repeated hello re-announces, which is harmless: port_connected is idempotent room-side.
263
+ transmit_port_connected
264
+ end
265
+ end
266
+
129
267
  def ping!
130
268
  return if left?
131
- if @has_established
269
+ if presumed_established?
270
+ # The room already heard from us if we sent anything since the last interval; the next
271
+ # ping lands within 2 x PING_INTERVAL of that, well inside PORT_TIMEOUT.
132
272
  port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true) unless recently_transmitted?
133
- else
273
+ elsif @hello_received
274
+ # Announced but never acknowledged: the announcement or the acknowledgement was lost in
275
+ # transit, or the room did not exist yet. An unestablished membership silently drops
276
+ # everything the client sends (see RoomProxyChannel#receive), so re-announce instead of
277
+ # pinging: port_connected is idempotent on the room side (the port is merged, user_joined
278
+ # fires only once). Before hello there is nothing to heal — the client isn't ready.
134
279
  transmit_port_connected
135
280
  end
136
281
  @mutex.synchronize do
@@ -142,7 +287,6 @@ module CableRoom
142
287
  @mutex.synchronize do
143
288
  return if left?
144
289
 
145
- stop_reannouncing
146
290
  close_streamed_ports!
147
291
  @cable_channel._room_memberships.delete(self)
148
292
  port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_disconnected' }, secure_context: true)
@@ -162,24 +306,11 @@ module CableRoom
162
306
 
163
307
  def key; @room_key; end
164
308
 
165
- # @internal
166
- def streams_live!
167
- @streams_live = true
168
- end
169
-
170
- # @internal
171
- def transmit_port_connected
172
- msg = {
173
- type: 'port_connected',
174
- tags: @tags,
175
- }
176
- msg[:extra] = ::ActiveJob::Arguments.serialize([@extra]) if @extra
177
- port_transmit(room_class::ROOM_IN_CHANNEL, msg, secure_context: true)
178
- schedule_reannounce
179
- end
180
-
181
- protected
309
+ # -- The member side of Ports ---------------------------------------------------------------
182
310
 
311
+ # Everything a member sends goes to its room, so it's published on the room's Bus channel for
312
+ # `port` (see Room::Base.inbound_channel). The room's Host is subscribed there and queues the
313
+ # message on the room. Public because `ports[:x] << msg` reaches it through a PortProxy.
183
314
  def port_transmit(port, data, secure_context: false)
184
315
  data[:mtok] = @token
185
316
 
@@ -191,49 +322,31 @@ module CableRoom
191
322
  end
192
323
  end
193
324
 
194
- super(port, data)
325
+ CableRoom.bus.publish(room_class.inbound_channel(@room_key, port), data)
195
326
  # Pings don't count: a ping must never be the reason the next ping is skipped.
196
327
  @last_transmit_at = monotonic_now unless type == :port_ping
197
328
  end
198
329
 
199
- # Re-announce until the room acknowledges the port, doubling the wait each time. Handing off
200
- # to ping! at PING_INTERVAL keeps a stuck membership from holding a timer open forever.
201
- def schedule_reannounce
202
- @mutex.synchronize do
203
- cancel_reannounce
204
- return if @has_established || @has_left
205
- return if ChannelTracker.instance.shutdown?
206
-
207
- next_delay = @reannounce_delay ? @reannounce_delay * 2 : RoomMember::REANNOUNCE_INITIAL_DELAY
208
- return if next_delay > RoomMember::PING_INTERVAL
209
-
210
- @reannounce_delay = next_delay
211
-
212
- # async_exec runs the block with `instance_exec` against the channel, so hold onto the
213
- # membership rather than relying on `self`.
214
- membership = self
215
- channel = @cable_channel
216
- @reannounce_job = ChannelTracker.instance.scheduler.schedule_in(@reannounce_delay.to_f) do
217
- channel.connection.worker_pool.async_exec(channel, connection: channel.connection) do
218
- membership.transmit_port_connected unless membership.connected? || membership.left?
219
- end
220
- end
221
- end
330
+ # Room→member traffic arrives on ActionCable streams (the broadcaster publishes to them), so
331
+ # listening on a port is a plain stream_from on the member's channel.
332
+ #
333
+ # Subscribing is asynchronous: anything published to the port before the pubsub adapter
334
+ # confirms the subscription can be lost. ActionCable confirms the channel subscription to the
335
+ # client only after every stream is live, which is why members wait for the client's `hello`
336
+ # (see #hello!) rather than announcing themselves from here.
337
+ def stream_port(port, auto_close: true, &blk)
338
+ @cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
339
+ _streamed_ports << port if auto_close
222
340
  end
223
341
 
224
- def cancel_reannounce
225
- @mutex.synchronize do
226
- @reannounce_job&.unschedule
227
- @reannounce_job = nil
342
+ def close_streamed_ports!
343
+ _streamed_ports.each do |port|
344
+ @cable_channel.stop_stream_from(room_port_key(port))
228
345
  end
346
+ _streamed_ports.clear
229
347
  end
230
348
 
231
- def stop_reannouncing
232
- @mutex.synchronize do
233
- cancel_reannounce
234
- @reannounce_delay = nil
235
- end
236
- end
349
+ protected
237
350
 
238
351
  def recently_transmitted?
239
352
  @last_transmit_at && (monotonic_now - @last_transmit_at) < RoomMember::PING_INTERVAL
@@ -255,13 +368,12 @@ module CableRoom
255
368
  # A re-announced port (see ping!) is re-acknowledged; only the first one is a join.
256
369
  first_acknowledgement = !@has_established
257
370
  @has_established = true
258
- stop_reannouncing
259
371
  @on_joined&.call(self) if first_acknowledgement
260
372
  when 'room_opened'
261
- # Only safe once our own streams are confirmed; otherwise the room may acknowledge on the
262
- # private port before anyone is listening. If we are not live yet, the subscription
263
- # confirmation hook will announce for us.
264
- transmit_port_connected if @streams_live
373
+ # The room we were waiting for just came up. Announce again if the client is ready;
374
+ # before hello the announcement waits for hello itself, and a premature one would be
375
+ # acknowledged into a stream the client isn't listening on yet.
376
+ transmit_port_connected if @hello_received
265
377
  @on_room_opened&.call(self)
266
378
  when 'room_closed'
267
379
  leave!
@@ -281,9 +393,14 @@ module CableRoom
281
393
 
282
394
  @token = SecureRandom.hex(16)
283
395
  @has_established = false
284
- stop_reannouncing
285
396
  @cable_channel._room_memberships << self
286
397
 
398
+ # Subscribing is asynchronous, and the room acknowledges on the private @token stream. We
399
+ # never announce here: port_connected waits for the client's hello (see hello!), which
400
+ # only arrives after ActionCable has confirmed the subscription — that is, after every one
401
+ # of these streams is live. A rejoin! is the exception: hello already happened for this
402
+ # socket, so announce now and let ping! repeat it if the room misses it.
403
+
287
404
  # Listen to public/broadcast channel
288
405
  stream_port(room_class::ROOM_OUT_CHANNEL) do |message|
289
406
  handle_received_message(message)
@@ -309,20 +426,79 @@ module CableRoom
309
426
 
310
427
  @preconfigure&.call(self)
311
428
 
312
- # Announcing here races the async stream subscriptions: the room can acknowledge on the
313
- # private port before it is live and the ack is lost. transmit_subscription_confirmation
314
- # announces instead, once every stream is confirmed.
429
+ transmit_port_connected if @hello_received
315
430
 
316
431
  maybe_provision_room
317
432
  end
318
433
  end
319
434
 
435
+ def restore_from(cable_channel, record)
436
+ @mutex = Monitor.new
437
+ @has_left = false
438
+ # Never learned here (see #hears_room?); the browser sees the acknowledgement instead.
439
+ @has_established = false
440
+
441
+ @cable_channel = cable_channel
442
+ @room_class = record.fetch("room_class").constantize
443
+ @room_key = record.fetch("room_key")
444
+ @token = record.fetch("token")
445
+ @tags = Array(record["tags"]).map(&:to_sym)
446
+ @serialized_extra = record["extra"]
447
+ @allow_create = record["create"] == true
448
+ @hello_received = record["hello_received"] == true
449
+ end
450
+
451
+ def transmit_port_connected
452
+ msg = {
453
+ type: 'port_connected',
454
+ tags: @tags,
455
+ }
456
+ msg[:extra] = serialized_extra if serialized_extra
457
+ port_transmit(room_class::ROOM_IN_CHANNEL, msg, secure_context: true)
458
+ end
459
+
460
+ # `extra` as the room receives it. Serialized once: a restored membership only ever has this
461
+ # form (see #persisted_identity), a fresh one builds it from what join_room was given.
462
+ def serialized_extra
463
+ return @serialized_extra if defined?(@serialized_extra)
464
+
465
+ @serialized_extra = @extra ? ::ActiveJob::Arguments.serialize([@extra]) : nil
466
+ end
467
+
468
+ # Ask the rooms hosts to start our room if `create: true` asked for it and nobody has
469
+ # acknowledged us yet. This never starts a room in the member's process: it publishes a
470
+ # `provision` request on the Bus, and every Host (the web process's own in :inline, the
471
+ # `cable_room server` fleet in :remote) hears it and races for the room's lock, the least
472
+ # loaded one first (see CableRoom::Placement). One code path, both modes.
473
+ #
474
+ # Called at join and again on every ping until the room acknowledges the port, so a lost
475
+ # request costs at most one ping interval, and a room whose host died comes back on the next
476
+ # ping from any member. Once acknowledged the room plainly exists, so we stop asking.
320
477
  def maybe_provision_room
321
478
  return if left?
322
479
  return unless @allow_create
323
- return if ChannelTracker.instance.shutdown?
480
+ return if @has_established
324
481
 
325
- @room_class.ensure(@room_key)
482
+ if CableRoom.config.inline?
483
+ # In :inline this process hosts rooms itself, so make sure its Host is up and listening
484
+ # before asking; otherwise the very first request in a fresh process would go unheard.
485
+ return if Host.instance.shutdown?
486
+ end
487
+
488
+ request = {
489
+ type: "provision",
490
+ room_class: room_class.name,
491
+ # Keys travel the way `extra` does, so a record key arrives on the host as the record
492
+ room_key: ::ActiveJob::Arguments.serialize([@room_key]),
493
+ requested_at: Time.current,
494
+ }
495
+
496
+ ActiveSupport::Notifications.instrument(
497
+ "provision_requested.cable_room",
498
+ { membership: self, room_class: room_class, room_key: @room_key, channel: @cable_channel, request: request }
499
+ ) do
500
+ CableRoom.bus.publish(Bus.provision_channel, request)
501
+ end
326
502
  end
327
503
  end
328
504
  end