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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -0
- data/README.md +1190 -0
- data/cable_room.gemspec +5 -2
- data/exe/cable_room +8 -0
- data/lib/cable_room/broadcaster.rb +116 -0
- data/lib/cable_room/bus.rb +372 -0
- data/lib/cable_room/cli.rb +237 -0
- data/lib/cable_room/config.rb +112 -0
- data/lib/cable_room/host/bus_inbound.rb +36 -0
- data/lib/cable_room/host/runner.rb +571 -0
- data/lib/cable_room/host/supervisor.rb +275 -0
- data/lib/cable_room/host/worker_pool.rb +37 -0
- data/lib/cable_room/host.rb +477 -0
- data/lib/cable_room/membership_store.rb +105 -0
- data/lib/cable_room/migration.rb +586 -0
- data/lib/cable_room/periodic_timer.rb +18 -0
- data/lib/cable_room/placement.rb +258 -0
- data/lib/cable_room/ports.rb +19 -11
- data/lib/cable_room/railtie.rb +3 -12
- data/lib/cable_room/room/base.rb +45 -39
- data/lib/cable_room/room/host_adapter.rb +52 -0
- data/lib/cable_room/room/lifecycle.rb +26 -9
- data/lib/cable_room/room/port_management.rb +57 -0
- data/lib/cable_room/room/reaping.rb +34 -1
- data/lib/cable_room/room/snapshotting.rb +78 -0
- data/lib/cable_room/room/threading.rb +2 -2
- data/lib/cable_room/room/user_management.rb +27 -0
- data/lib/cable_room/room.rb +5 -2
- data/lib/cable_room/room_member.rb +260 -84
- data/lib/cable_room/room_proxy_channel.rb +13 -2
- data/lib/cable_room/snapshot.rb +136 -0
- data/lib/cable_room/version.rb +1 -1
- data/lib/cable_room.rb +57 -2
- metadata +25 -9
- data/lib/cable_room/channel_base.rb +0 -262
- data/lib/cable_room/channel_tracker.rb +0 -130
- 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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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) || []
|
data/lib/cable_room/room.rb
CHANGED
|
@@ -7,9 +7,10 @@ module CableRoom
|
|
|
7
7
|
|
|
8
8
|
autoload :Callbacks
|
|
9
9
|
autoload :Threading
|
|
10
|
-
autoload :
|
|
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
|
-
|
|
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 ||=
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
200
|
-
#
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
@
|
|
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
|
-
|
|
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
|
-
#
|
|
262
|
-
#
|
|
263
|
-
#
|
|
264
|
-
transmit_port_connected if @
|
|
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
|
-
|
|
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
|
|
480
|
+
return if @has_established
|
|
324
481
|
|
|
325
|
-
|
|
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
|